14 Commits

Author SHA1 Message Date
Claude 362a22bbc6 feat(desktop): count-free progress indicator + infinite-scroll stream list
Follow-up to windowed particle pagination: adapt the stream UI now that the
loaded set is a window rather than the whole stream.

Progress indicator: drop the absolute "{n} / {total}" counter (the true count
is unknown when paginated) in favour of a streaming, count-free scrubber. It
renders a sliding window of segments around the current position (newest on
the right); the edge stubs page through the loaded window and pull in older
history via onLoadOlder when scrubbing past the oldest loaded segment.

Stream list sidebar: auto-fetch older particles via an IntersectionObserver
sentinel as the user scrolls toward the top, with viewport scroll-anchoring so
prepended history doesn't jolt the view. Auto-scroll-to-current is now gated on
genuine selection changes (not index shifts from prepends). Header count gains
a "+" while more history exists, and a spinner shows while paging.

ScrollArea gains an optional `viewportRef` to expose the scroll viewport for
anchoring and observers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8gsmdVd7R8PJtn4UFnC4J
2026-06-21 02:22:04 +00:00
Claude af69f98583 feat(desktop): paginate stream particles via windowed Firestore subscription
Streams previously prefetched every particle through an unbounded Firestore
subscription. This adds a windowed source that anchors a `created_at desc`
limit query at the newest particle and grows it backward on demand, so the
already-seen history before a viewer's playback marker is no longer loaded.

- `useWindowedStreamParticles`: tail-anchored live window that grows backward
  to cover the resume marker and to service `loadOlder()` (list scroll-up).
  Because the window always includes the newest particle, new arrivals stream
  in and forward playback never needs a fetch. Includes anti-eviction growth
  so a new tail particle never pushes loaded particles out of the window.
- `useStreamPlayback`: consumes the windowed source instead of loading all
  children. New-tail and removal handling are derived from the children array
  (Firestore change events can't tell a genuine arrival from pagination
  backfill). Resume-from-marker waits for backward growth to reach an older
  marker; `prev` at the window edge pulls in older history.

Playback resume, forward-only marker persistence, auto-advance-on-new, and
removal fallback are all preserved. List view and progress indicator continue
to render off the (now windowed) children; their pagination UX is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8gsmdVd7R8PJtn4UFnC4J
2026-06-21 01:54:39 +00:00
Arjun Patel bfe53b46cf chore: bump desktop version to v1.8.0 (#293) 2026-06-20 17:27:38 -07:00
Arjun Patel 129e4772dd fix: ui papercuts (#292)
* fix: avatar showing blank when switching

* fix: sidebar padding glitchy

Long markdown content would make each chat row expand and remove the padding in the sidebar

* ignore emacs project.el

* nit
2026-06-20 17:22:45 -07:00
Arjun Patel 92c12ad0bd feat: add limits to recordings on destop (#291)
* feat: add limits to recordings on destop

* code review
2026-06-20 17:22:22 -07:00
Arjun Patel c11c5074ce chore: bump version to v1.7.1 (#285) 2026-06-13 12:18:21 -07:00
Arjun Patel fa9f88f6f6 improve stream sidebar experience and avatar affordance (#284)
* fix awkward stream list experience

* fix avatar change affordance

* cleanup unnecessary logic

* cleanup unnecessary logic and complexity

* unused var
2026-06-13 11:39:29 -07:00
Arjun Patel 9249cb4107 chore: bump desktop to v1.7.0 (#281) 2026-06-12 12:52:05 -07:00
Arjun Patel 095b9876f9 feat: stream list view and tasks (#279)
* first attempt at stream sidebar, tasks, and events

* fix folder from root

* cleanup folders and events, and condense changes

* cleanup and add toggle for sidebar

* cleanup

* fix nits
2026-06-12 12:26:49 -07:00
Arjun Patel a358774106 ci: fix stuck on migration runs (#278) 2026-06-11 16:52:05 -07:00
Arjun Patel d24947d5ce fix: remove settings option from stream-view (#277)
* remove settings option

* remove unused var
2026-06-11 16:51:37 -07:00
Arjun Patel 9e3a30f74e bump version (#276) 2026-06-11 15:38:05 -07:00
Arjun Patel 4facc6b371 feat: avatars for humans (#273)
* implement avatar backend functionality

* add avatar endpoints

* typo

* implement client side avatar upload and handling

* fixes

* Update go/internal/handler/handler.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update js/desktop/src/lib/avatar-image.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* bug in order

* remove unused component

* fix invalid migration

* fix syntax errors

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 15:30:21 -07:00
Arjun Patel ca3bbf204e fix: text compose papercuts (#270)
* make fixes

* cleanup
2026-06-11 12:08:34 -07:00
76 changed files with 3873 additions and 1205 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
run: |
if [ "${{ inputs.module }}" = "migrations" ]; then
kubectl delete job migrations --ignore-not-found
skaffold run -p migrations --tail
skaffold run -p migrations
else
skaffold run -p ${{ inputs.environment }} -m ${{ inputs.module }}
fi
+1
View File
@@ -5,3 +5,4 @@ build/
compile_commands.json
CMakeLists.txt.user
tags
project.el
+2 -2
View File
@@ -38,12 +38,12 @@ PROD_REPO := us-west2-docker.pkg.dev/flowy-prod-440017/deployments
.PHONY: migrate-dev
migrate-dev:
kubectl delete job migrations --ignore-not-found --context=dev
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev --tail
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev
.PHONY: migrate-prod
migrate-prod:
kubectl delete job migrations --ignore-not-found --context=prod
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod --tail
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod
# ---- Deploy ----
# Use MODULE=orion or MODULE=particleprocessor or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.:
+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;
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Flowy.llink",
"productName": "Flowy.llink",
"version": "1.5.2",
"version": "1.8.0",
"description": "Flowy.llink is a video messaging app for teams",
"main": ".vite/build/main.js",
"private": true,
@@ -64,6 +64,7 @@
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@milkdown/crepe": "^7.21.1",
"@milkdown/kit": "7.21.1",
"@sentry/electron": "^7.11.0",
"@sentry/react": "^10.54.0",
"@tanstack/react-query": "^5.90.21",
+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) {
+42 -12
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>;
@@ -145,14 +146,21 @@ export const TextPropertiesSchema = z.object({
});
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
export const QuestPropertiesSchema = z.object({
export const ChecklistItemSchema = z.object({
text: z.string(),
done: z.boolean(),
});
export type ChecklistItem = z.infer<typeof ChecklistItemSchema>;
export const TaskPropertiesSchema = z.object({
title: z.string(),
description: z.string(),
status: z.string().optional(),
notes: z.string().optional(),
checklist: z.array(ChecklistItemSchema).optional(),
// humanId
assigned_to: z.string().optional(),
done: z.boolean(),
});
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
export const PaperPropertiesSchema = z.object({
title: z.string(),
@@ -193,7 +201,7 @@ export interface ParticlePropertiesMap {
media: MediaProperties;
file: FileProperties;
text: TextProperties;
quest: QuestProperties;
task: TaskProperties;
paper: PaperProperties;
}
@@ -210,6 +218,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
ParticleBaseSchema.extend({
type: z.literal('stream'),
properties: StreamPropertiesSchema,
// Open/closed lifecycle. Optional because some streams were created while
// the field was dropped — treat missing as 'open' (see isStreamOpen).
status: z.enum(['open', 'closed']).optional(),
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:xywx"] - visible to everyone in the network
visible_to: z.array(z.string()),
@@ -220,7 +231,6 @@ export const ParticleSchema = z.discriminatedUnion('type', [
last_child_created_at: z.coerce.date().optional(),
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
huddle_active_participants: z.array(z.string()).optional(),
status: z.enum(['open', 'closed']).optional(),
}),
ParticleBaseSchema.extend({
type: z.literal('folder'),
@@ -228,6 +238,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()),
// Set to created_at on creation, bumped when children are added — keeps
// folders present in activity-ordered container queries.
last_child_created_at: z.coerce.date().optional(),
}),
ParticleBaseSchema.extend({
type: z.literal('media'),
@@ -247,8 +260,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
...TombstoneFields,
}),
ParticleBaseSchema.extend({
type: z.literal('quest'),
properties: QuestPropertiesSchema,
type: z.literal('task'),
properties: TaskPropertiesSchema,
reactions: ReactionsSchema,
...TombstoneFields,
}),
ParticleBaseSchema.extend({
@@ -258,17 +272,28 @@ export const ParticleSchema = z.discriminatedUnion('type', [
}),
]);
export type Particle = z.infer<typeof ParticleSchema>;
// Placeholder for docs whose `type` this client version doesn't recognize
// (e.g. a newer client wrote a particle type we don't ship yet). The converter
// maps them here instead of throwing, so they degrade to an "unsupported"
// view rather than breaking the whole subscription.
export const UnknownParticleSchema = ParticleBaseSchema.extend({
type: z.literal('unknown'),
raw_type: z.string(),
});
export type UnknownParticle = z.infer<typeof UnknownParticleSchema>;
export type ParticleType = Particle['type'];
export type Particle = z.infer<typeof ParticleSchema> | UnknownParticle;
/** Particle types this client can read and write — excludes 'unknown'. */
export type ParticleType = z.infer<typeof ParticleSchema>['type'];
/** Container types can have children subcollections */
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set([
export const CONTAINER_TYPES: ReadonlySet<Particle['type']> = new Set([
'stream',
'folder',
]);
export function isContainerType(type: ParticleType): boolean {
export function isContainerType(type: Particle['type']): boolean {
return CONTAINER_TYPES.has(type);
}
@@ -277,6 +302,11 @@ export function isParticleDeleted(particle: Particle): boolean {
return 'deleted_at' in particle && particle.deleted_at != null;
}
/** Missing status counts as open (streams created while the field was dropped). */
export function isStreamOpen(stream: Extract<Particle, { type: 'stream' }>) {
return stream.status !== 'closed';
}
// --- LiveKit types ---
export const GetLivekitTokenResponseSchema = z.object({
@@ -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}>
<AvatarImage src={url} alt={initials} />
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
</Avatar>
);
}
@@ -1,5 +1,5 @@
import { Copy, ExternalLink, Globe } from 'lucide-react';
import type { LinkMetadata } from '@/lib/link-metadata';
import { domainFromUrl, type LinkMetadata } from '@/lib/link-metadata';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { platform } from '@/lib/platform';
@@ -93,6 +93,22 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
);
}
/** Shown when metadata couldn't be fetched — the link itself still works. */
export function LinkPreviewCardFallback({ url }: { url: string }) {
return (
<LinkPreviewCard
metadata={{
url,
domain: domainFromUrl(url),
title: url,
description: null,
image: null,
favicon: null,
}}
/>
);
}
export function LinkPreviewCardSkeleton() {
return (
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
+7 -2
View File
@@ -6,8 +6,12 @@ import { cn } from '@/lib/utils';
function ScrollArea({
className,
children,
viewportRef,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
/** Ref to the scrollable viewport, e.g. for scroll anchoring or observers. */
viewportRef?: React.Ref<HTMLDivElement>;
}) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
@@ -15,8 +19,9 @@ function ScrollArea({
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!w-full"
>
{children}
</ScrollAreaPrimitive.Viewport>
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -4,6 +4,7 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { LinkPreviewEntry } from '@/hooks/use-link-metadata';
import { domainFromUrl } from '@/lib/link-metadata';
import {
AttachmentLightbox,
getAttachmentHandler,
@@ -114,18 +115,19 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
);
}
if (!entry.metadata) return null;
// Metadata fetch can fail; fall back to the bare URL so the link stays usable.
const { metadata } = entry;
const domain = metadata?.domain ?? domainFromUrl(entry.url);
const title = metadata?.title ?? entry.url;
return (
<button
type="button"
onClick={() => platform.link.openExternal(metadata.url)}
onClick={() => platform.link.openExternal(metadata?.url ?? entry.url)}
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
>
<div className="flex items-center gap-1 text-[10px] text-white/40">
{metadata.favicon ? (
{metadata?.favicon ? (
<img
src={metadata.favicon}
alt=""
@@ -137,11 +139,11 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
) : (
<Globe className="size-3" />
)}
<span className="truncate">{metadata.domain}</span>
<span className="truncate">{domain}</span>
</div>
{metadata.title && (
{title && (
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
{metadata.title}
{title}
</p>
)}
</button>
@@ -19,7 +19,9 @@ import { RecordingOverlay } from '@/features/compose/recording-overlay';
import { ScreenSourcePicker } from '@/components/screen-source-picker';
import { KeyHint } from '@/components/key-hint';
import { TextComposeStep } from '@/features/compose/text-compose-step';
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
import { TaskComposeStep } from '@/features/compose/task-compose-step';
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
import type { TaskProperties } from '@/api/types';
import { apiClient } from '@/api/client';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
import { useMediaDevicesStore } from '@/stores/media-devices-store';
@@ -40,11 +42,14 @@ export type ComposeStep =
| 'recording'
| 'reviewing'
| 'typing'
| 'task'
| 'configuring'
| 'submitting';
type RecordingSource = 'media' | 'screen';
type PendingArtifact = { type: 'task'; properties: TaskProperties };
interface ComposeOverlayProps {
networkId: string;
// Optional target path for reply mode. If not provided, compose creates a new stream.
@@ -52,8 +57,6 @@ interface ComposeOverlayProps {
onActiveChange?: (active: boolean) => void;
onStepChange?: (step: ComposeStep) => void;
onParticleCreated?: (particleId: string) => void;
/** When true, composing is blocked (e.g. stream is closed). */
disabled?: boolean;
}
const HOLD_THRESHOLD_MS = 250;
@@ -68,7 +71,6 @@ export function ComposeOverlay({
onActiveChange,
onStepChange,
onParticleCreated,
disabled,
}: ComposeOverlayProps) {
const [step, setStep] = useState<ComposeStep>('idle');
const [error, setError] = useState<string | null>(null);
@@ -98,14 +100,14 @@ export function ComposeOverlay({
// Latest props/state for synchronous reads in keyboard handlers.
const stepRef = useRef(step);
const recordStartRef = useRef(0);
const disabledRef = useRef(disabled);
const quotaExhaustedRef = useRef(quotaExhausted);
const recordingSourceRef = useRef(recordingSource);
const pendingArtifactRef = useRef<PendingArtifact | null>(null);
useEffect(() => {
disabledRef.current = disabled;
quotaExhaustedRef.current = quotaExhausted;
recordingSourceRef.current = recordingSource;
}, [disabled, quotaExhausted, recordingSource]);
}, [quotaExhausted, recordingSource]);
const setStepSync = useCallback((next: ComposeStep) => {
stepRef.current = next;
@@ -142,6 +144,7 @@ export function ComposeOverlay({
setReviewDurationMs(0);
setReviewMimeType(null);
setRecordingSource('media');
pendingArtifactRef.current = null;
setAttachments((prev) => {
revokeAttachmentThumbnails(prev);
return [];
@@ -330,7 +333,15 @@ export function ComposeOverlay({
if (!userId) return;
let particleId: undefined | string;
if (textContent.trim()) {
const pendingArtifact = pendingArtifactRef.current;
if (pendingArtifact) {
particleId = await createParticle.mutateAsync({
path,
type: pendingArtifact.type,
properties: pendingArtifact.properties,
createdByHumanId: userId,
});
} else if (textContent.trim()) {
particleId = await createParticle.mutateAsync({
path,
type: 'text',
@@ -454,14 +465,10 @@ export function ComposeOverlay({
// --- Compose intent handlers ---
// Single source of truth for the step transitions triggered by the user.
// Both the keyboard handler and the intent store dispatch into these so
// guards (disabled, quota) and screen-vs-media branching live in one place.
// guards (quota) and screen-vs-media branching live in one place.
const guardIdle = useCallback((): boolean => {
if (stepRef.current !== 'idle') return false;
if (disabledRef.current) {
toast.info('This stream is closed');
return false;
}
if (quotaExhaustedRef.current) {
toast.info(
'Daily message limit reached. Upgrade to Pro to keep sending.',
@@ -484,6 +491,26 @@ export function ComposeOverlay({
setStepSync('typing');
}, [guardIdle, setStepSync]);
const handleTaskIntent = useCallback(() => {
if (!guardIdle()) return;
setStepSync('task');
}, [guardIdle, setStepSync]);
// Artifact submit: capture the artifact, then reuse the standard flow —
// reply mode creates it under targetPath, root mode configures a stream
// that will hold it as its first child.
const handleArtifactSubmit = useCallback(
(artifact: PendingArtifact) => {
pendingArtifactRef.current = artifact;
if (targetPath) {
void onSubmitReply();
} else {
setStepSync('configuring');
}
},
[targetPath, onSubmitReply, setStepSync],
);
const handleStopIntent = useCallback(() => {
if (stepRef.current !== 'recording') return;
if (recordingSourceRef.current === 'screen') {
@@ -537,6 +564,9 @@ export function ComposeOverlay({
case 'text':
handleTextIntent();
break;
case 'task':
handleTaskIntent();
break;
case 'stop':
handleStopIntent();
break;
@@ -552,6 +582,7 @@ export function ComposeOverlay({
}, [
handleRecordIntent,
handleTextIntent,
handleTaskIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
@@ -566,6 +597,7 @@ export function ComposeOverlay({
if (
currentStep === 'typing' ||
currentStep === 'task' ||
currentStep === 'configuring' ||
currentStep === 'picking'
) {
@@ -599,6 +631,9 @@ export function ComposeOverlay({
} else if (e.key === 't' || e.key === 'T') {
e.preventDefault();
handleTextIntent();
} else if (e.key === 'd' || e.key === 'D') {
e.preventDefault();
handleTaskIntent();
}
break;
}
@@ -666,6 +701,7 @@ export function ComposeOverlay({
guardIdle,
handleRecordIntent,
handleTextIntent,
handleTaskIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
@@ -774,8 +810,17 @@ export function ComposeOverlay({
dropZoneProps={dropZoneProps}
/>
)}
{step === 'task' && (
<TaskComposeStep
networkId={networkId}
onCancel={cancel}
onSubmit={(properties) =>
handleArtifactSubmit({ type: 'task', properties })
}
/>
)}
{!targetPath && step === 'configuring' && (
<ConfigureStreamStep
<ConfigureContainerStep
networkId={networkId}
onCancel={cancel}
onSubmit={handleStreamSubmit}
@@ -10,17 +10,20 @@ import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
interface ConfigureStreamStepProps {
interface ConfigureContainerStepProps {
/** Drives labels and hints; the form is identical for both kinds. */
kind?: 'stream' | 'folder';
networkId: string | null;
onCancel: () => void;
onSubmit: (streamName: string, visibleTo: string[]) => void;
onSubmit: (name: string, visibleTo: string[]) => void;
}
export function ConfigureStreamStep({
export function ConfigureContainerStep({
kind = 'stream',
networkId,
onCancel,
onSubmit,
}: ConfigureStreamStepProps) {
}: ConfigureContainerStepProps) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
@@ -79,9 +82,10 @@ export function ConfigureStreamStep({
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
{/* Stream name */}
<div>
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
<Label className="mb-1 text-xs text-white/50">
{kind === 'folder' ? 'Folder name' : 'Stream name'}
</Label>
<Input
type="text"
autoFocus
@@ -165,7 +169,7 @@ export function ConfigureStreamStep({
<KeyHint
keys={`${metaKey}+Enter`}
onClick={handleSubmit}
title={`Create stream (or press ${metaKey}+Enter)`}
title={`Create ${kind} (or press ${metaKey}+Enter)`}
>
create
</KeyHint>
@@ -57,6 +57,80 @@
.llink-crepe .milkdown .ProseMirror {
padding: 0;
outline: none;
caret-color: white;
}
/* Crepe's default heading scale (42px h1) is sized for a document editor;
* tighten it to message-card proportions. Applies to compose and read-only
* alike so the editing view matches the posted card. */
.llink-crepe .milkdown .ProseMirror h1 {
font-size: 1.875rem;
line-height: 2.375rem;
font-weight: 600;
margin-top: 24px;
}
.llink-crepe .milkdown .ProseMirror h2 {
font-size: 1.5rem;
line-height: 2rem;
font-weight: 600;
margin-top: 20px;
}
.llink-crepe .milkdown .ProseMirror h3 {
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 600;
margin-top: 16px;
}
.llink-crepe .milkdown .ProseMirror h4 {
font-size: 1.125rem;
line-height: 1.625rem;
font-weight: 600;
margin-top: 12px;
}
.llink-crepe .milkdown .ProseMirror h5 {
font-size: 1rem;
line-height: 1.5rem;
font-weight: 600;
margin-top: 12px;
}
.llink-crepe .milkdown .ProseMirror h6 {
font-size: 0.875rem;
line-height: 1.375rem;
font-weight: 700;
margin-top: 12px;
color: rgb(255 255 255 / 0.7);
}
.llink-crepe .milkdown .ProseMirror > :first-child {
margin-top: 0;
}
/* Images come from URLs only (no stable public upload URL), so hide the
* file uploader — the placeholder then just prompts for a link. */
.llink-crepe
.milkdown
:is(.milkdown-image-block, .milkdown-image-inline)
.placeholder
.uploader {
display: none;
}
/* Read-only renders the image node view with inert editing chrome — hide it. */
.llink-crepe:not(.llink-crepe--fill) .milkdown .milkdown-image-block .operation,
.llink-crepe:not(.llink-crepe--fill)
.milkdown
.milkdown-image-block
.image-resize-handle {
display: none;
}
.llink-crepe .milkdown .milkdown-image-block img {
border-radius: 8px;
}
/* Editing context: fill the compose card and scroll internally so a long
@@ -71,8 +145,10 @@
}
/* Pad the content (not the card) so the slash menu — which Crepe appends to
* .milkdown — can use the full card width/height before clipping. */
* .milkdown — can use the full card width/height before clipping. The shared
* layout variables (globals.css) keep the editor's content column identical
* to the posted card's, and the gutter fits the block drag handle. */
.llink-crepe--fill .milkdown .ProseMirror {
min-height: 100%;
padding: 1.25rem;
padding: var(--message-card-padding) var(--message-editor-gutter);
}
@@ -1,5 +1,7 @@
import { useEffect, useRef } from 'react';
import { Crepe } from '@milkdown/crepe';
import { editorViewCtx } from '@milkdown/kit/core';
import { Selection } from '@milkdown/kit/prose/state';
import '@milkdown/crepe/theme/common/style.css';
import '@milkdown/crepe/theme/frame-dark.css';
import './markdown-editor.css';
@@ -55,13 +57,23 @@ export function MarkdownEditor({
[Crepe.Feature.BlockEdit]: !readOnly,
[Crepe.Feature.Toolbar]: !readOnly,
[Crepe.Feature.Placeholder]: !readOnly,
[Crepe.Feature.ImageBlock]: false,
[Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Latex]: false,
[Crepe.Feature.TopBar]: false,
[Crepe.Feature.AI]: false,
},
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
// Images come from URLs only (e.g. pasted markdown) — there is no
// stable public upload URL, so the file uploader is hidden in CSS and
// onUpload rejects in case a file ever reaches it anyway (the default
// would serialize an ephemeral blob: URL into the message).
[Crepe.Feature.ImageBlock]: {
blockUploadPlaceholderText: 'Paste an image link…',
inlineUploadPlaceholderText: 'paste an image link',
maxHeight: 420,
onUpload: () => Promise.reject(new Error('Image uploads disabled')),
},
},
});
@@ -82,7 +94,18 @@ export function MarkdownEditor({
}
created = crepe;
if (autoFocus && !readOnly) {
root.querySelector<HTMLElement>('.ProseMirror')?.focus();
// Place the caret at the end of the document — the editor often mounts
// mid-typing (immersive → card flip), where start-of-doc would strand
// the user. Selection-only transactions don't echo markdownUpdated.
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
view.dispatch(
view.state.tr
.setSelection(Selection.atEnd(view.state.doc))
.scrollIntoView(),
);
view.focus();
});
}
});
@@ -97,7 +120,11 @@ export function MarkdownEditor({
return (
<div
ref={rootRef}
className={cn('llink-crepe', !readOnly && 'llink-crepe--fill', className)}
className={cn(
'llink-crepe',
!readOnly && 'llink-crepe--fill scrollbar-card',
className,
)}
/>
);
}
@@ -7,6 +7,10 @@ import { useObjectUrl } from '@/hooks/use-object-url';
import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { cn } from '@/lib/utils';
import {
RECORDING_MAX_DURATION_SECONDS,
RECORDING_WARNING_SECONDS,
} from '@/lib/constants';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
@@ -34,16 +38,50 @@ interface RecordingOverlayProps {
objectFit?: 'cover' | 'contain';
}
function RecordingTimer() {
const WARNING_AT_SECONDS =
RECORDING_MAX_DURATION_SECONDS - RECORDING_WARNING_SECONDS;
/**
* Tracks elapsed recording time and drives the time-limit UI. Keeps the
* recorder itself unaware of limits: when the cap is reached it dispatches the
* standard `stop` intent (the same path as releasing the ` key), which finishes
* the recording into the review step.
*/
function useRecordingCountdown(active: boolean) {
const [elapsed, setElapsed] = useState(0);
const requestIntent = useComposeIntentStore((s) => s.request);
useEffect(() => {
const interval = setInterval(() => {
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
if (!active) return;
const start = Date.now();
let stopped = false;
// Tick faster than 1s so the auto-stop lands within ~250ms of the cap, but
// only re-render when the whole-second value actually changes.
const interval = setInterval(() => {
const seconds = Math.floor((Date.now() - start) / 1000);
setElapsed((prev) => (prev === seconds ? prev : seconds));
if (seconds >= RECORDING_MAX_DURATION_SECONDS && !stopped) {
stopped = true;
requestIntent('stop');
}
}, 250);
return () => {
clearInterval(interval);
setElapsed(0);
};
}, [active, requestIntent]);
return { elapsed, isWarning: elapsed >= WARNING_AT_SECONDS };
}
function RecordingTimer({
elapsed,
isWarning,
}: {
elapsed: number;
isWarning: boolean;
}) {
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
@@ -51,7 +89,14 @@ function RecordingTimer() {
return (
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
<span className="font-mono text-sm text-white/80">{display}</span>
<span
className={cn(
'font-mono text-sm text-white/80',
isWarning && 'text-red-400',
)}
>
{display}
</span>
</div>
);
}
@@ -137,14 +182,31 @@ export function RecordingOverlay({
const isLoading = isRecording && !mediaStream;
const requestIntent = useComposeIntentStore((s) => s.request);
const { elapsed, isWarning } = useRecordingCountdown(
isRecording && !isLoading,
);
return (
<div
className={cn(
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
isRecording && isWarning && 'record-warning-glow',
)}
{...(isReviewing ? dropZoneProps : {})}
>
{/* Top progress bar: fills over the recording duration, red in warning */}
{isRecording && !isLoading && (
<div className="absolute inset-x-0 top-0 z-20 h-1 bg-white/10">
<div
className={cn(
'h-full w-full origin-left record-progress',
isWarning ? 'bg-red-500' : 'bg-white/80',
)}
style={{ animationDuration: `${RECORDING_MAX_DURATION_SECONDS}s` }}
/>
</div>
)}
{/* Loading state */}
{isLoading && (
<div className="z-10 flex flex-col items-center gap-2">
@@ -185,7 +247,7 @@ export function RecordingOverlay({
{/* Top center: recording indicator */}
<div className="absolute top-8 z-10">
{isRecording && !isLoading ? (
<RecordingTimer />
<RecordingTimer elapsed={elapsed} isWarning={isWarning} />
) : isReviewing ? (
<div className="flex items-center gap-2">
<span className="text-sm text-white/80">Review recording</span>
@@ -0,0 +1,125 @@
import { useCallback, useState } from 'react';
import type { TaskProperties } from '@/api/types';
import { useNetwork } from '@/hooks/use-networks';
import { metaKey } from '@/lib/platform';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { KeyHint } from '@/components/key-hint';
const UNASSIGNED = 'unassigned';
interface TaskComposeStepProps {
networkId: string;
onCancel: () => void;
onSubmit: (properties: TaskProperties) => void;
}
/**
* Minimal task creation form. Checklist items are added after creation in
* the always-editable task view, keeping this step a quick capture.
*/
export function TaskComposeStep({
networkId,
onCancel,
onSubmit,
}: TaskComposeStepProps) {
const network = useNetwork(networkId);
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [assignedTo, setAssignedTo] = useState<string>(UNASSIGNED);
const handleSubmit = useCallback(() => {
const trimmed = title.trim();
if (!trimmed) return;
onSubmit({
title: trimmed,
...(notes.trim() && { notes: notes.trim() }),
...(assignedTo !== UNASSIGNED && { assigned_to: assignedTo }),
checklist: [],
done: false,
});
}, [title, notes, assignedTo, onSubmit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
},
[onCancel, handleSubmit],
);
return (
<div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
<div>
<Label className="mb-1 text-xs text-white/50">Task</Label>
<Input
type="text"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to get done?"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
<div>
<Label className="mb-1 text-xs text-white/50">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional details…"
className="min-h-20 border-white/10 bg-white/5 text-white placeholder:text-white/30 focus-visible:border-white/30 focus-visible:ring-0 dark:bg-white/5"
/>
</div>
<div>
<Label className="mb-1 text-xs text-white/50">Assign to</Label>
<Select value={assignedTo} onValueChange={setAssignedTo}>
<SelectTrigger className="w-full border-white/10 bg-white/5 text-white">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
{network?.humans?.map((human) => (
<SelectItem key={human.id} value={human.id}>
{human.email_prefix}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</KeyHint>
<KeyHint
keys={`${metaKey}+Enter`}
onClick={handleSubmit}
title={`Create task (or press ${metaKey}+Enter)`}
>
create
</KeyHint>
</div>
</div>
);
}
+17 -16
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Paperclip } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getImmersiveTextStyle } from '@/lib/immersive-text';
import { hasMarkdownFormatting } from '@/lib/markdown';
import { metaKey } from '@/lib/platform';
import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
import { AttachmentStrip } from '@/features/compose/attachment-strip';
@@ -35,12 +37,6 @@ interface TextEditorProps {
const IMMERSIVE_CHAR_LIMIT = 120;
function getImmersiveTextStyle(length: number) {
if (length < 70) return { size: 'text-5xl', weight: 'font-semibold' };
if (length < 130) return { size: 'text-3xl', weight: 'font-semibold' };
return { size: 'text-2xl', weight: 'font-normal' };
}
export function TextEditor({
textContent,
onTextChange,
@@ -50,7 +46,9 @@ export function TextEditor({
attachmentProps,
}: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [forceCardMode, setForceCardMode] = useState(false);
// Card mode latches: once the message needs the full editor, snapping back
// to immersive mid-edit would be jarring, so it stays for the session.
const [carded, setCarded] = useState(false);
const [debouncedText, setDebouncedText] = useState(textContent);
useEffect(() => {
@@ -61,13 +59,16 @@ export function TextEditor({
const attachmentCount = attachmentProps?.attachments.length ?? 0;
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
const immersive =
textContent.length < IMMERSIVE_CHAR_LIMIT &&
!hasEnrichments &&
!forceCardMode;
const shouldCard =
!carded &&
(textContent.length >= IMMERSIVE_CHAR_LIMIT ||
hasEnrichments ||
hasMarkdownFormatting(textContent));
if (shouldCard) setCarded(true);
const immersive = !carded;
// Keep the immersive textarea focused with the caret at the end when we
// (re)enter it. The card-mode editor manages its own focus.
// Keep the immersive textarea focused with the caret at the end on mount.
// The card-mode editor manages its own focus.
useEffect(() => {
if (!immersive) return;
const t = setTimeout(() => {
@@ -95,7 +96,7 @@ export function TextEditor({
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
setForceCardMode(true);
setCarded(true);
}
},
[onCancel, onSubmit, textContent],
@@ -127,7 +128,7 @@ export function TextEditor({
{immersive && (
<KeyHint
keys={`${metaKey}+M`}
onClick={() => setForceCardMode(true)}
onClick={() => setCarded(true)}
title={`Switch to markdown editor (or press ${metaKey}+M)`}
>
markdown
@@ -194,7 +195,7 @@ export function TextEditor({
{...dropZoneProps}
onKeyDownCapture={handleKeyDown}
>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl">
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[calc(var(--message-content-width)_+_var(--message-editor-gutter)*2)] flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl animate-in fade-in zoom-in-95 duration-200">
{/* No padding here: the editor's own scroll box hosts the slash menu,
so we pad inside the editor (ProseMirror) instead. That keeps the
menu's clipping bounds the full card rather than the inset box. */}
+1 -19
View File
@@ -16,30 +16,12 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useNetworks } from '@/hooks/use-networks';
import { particlePath } from '@/lib/particle-path';
import { useParticle } from '@/hooks/use-particle';
import type { Particle } from '@/api/types';
import { getParticleDisplayName } from '@/lib/particle-display';
import { PropsWithChildren, useCallback } from 'react';
import { useDockBadge } from '@/hooks/use-dock-badge';
import { toast } from 'sonner';
import { RouteErrorBoundary } from '@/components/app-error-boundary';
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'text':
return particle.properties.content.slice(0, 30);
case 'media':
return particle.type;
}
}
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
+5 -116
View File
@@ -1,126 +1,15 @@
import { useCallback, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { CircleDot, CircleCheckBig } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { particlePath } from '@/lib/particle-path';
import { ParticleListView } from '@/features/particles/particle-list-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { ComposeOverlay } from './compose/compose-overlay';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useStreamParticles } from '@/hooks/use-stream-particles';
import { useStreamKeyboardNav } from '@/hooks/use-stream-keyboard-nav';
import { ContainerView } from '@/features/particles/container-view';
/**
* Route-level component for /:networkId (index).
* Shows root-level particles for the selected network.
* Route-level component for /:networkId (index). The network root behaves
* like a folder: a container of streams, folders, and loose particles.
*/
export default function NetworkRoot() {
const { networkId } = useParams();
if (!networkId)
throw new Error('NetworkRoot requires a :networkId route param');
const navigate = useNavigate();
const path = particlePath(networkId, []);
const [composeActive, setComposeActive] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const statusTab: 'open' | 'closed' =
searchParams.get('status') === 'closed' ? 'closed' : 'open';
const setStatusTab = (next: 'open' | 'closed') => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set('status', next);
return params;
},
{ replace: true },
);
};
const { streams, isLoading, canLoadMore, loadMore } = useStreamParticles(
path,
{
status: statusTab,
},
);
const { selectedIndex } = useStreamKeyboardNav({
streams,
enabled: !composeActive,
onNavigate: useCallback(
(streamId: string) => navigate(`/${networkId}/${streamId}`),
[navigate, networkId],
),
});
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center p-1 border-b">
<Tabs
value={statusTab}
onValueChange={(v) =>
setStatusTab(v === 'closed' ? 'closed' : 'open')
}
>
<TabsList>
<TabsTrigger value="open">
<CircleDot className="size-3 text-green-500" /> Open
</TabsTrigger>
<TabsTrigger value="closed">
<CircleCheckBig className="size-3" /> Closed
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
<ParticleListView
streams={streams}
networkId={networkId}
isLoading={isLoading}
selectedIndex={selectedIndex}
canLoadMore={canLoadMore}
onLoadMore={loadMore}
/>
</div>
<ComposeOverlay networkId={networkId} onActiveChange={setComposeActive} />
{!composeActive && (
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
<ComposeQuotaIndicator networkId={networkId} />
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
<div className="pointer-events-auto">
<NetworkRootControls />
</div>
</div>
</div>
);
}
function NetworkRootControls() {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<KeyHint keys="19">jump</KeyHint>
<VideoAudioToggle />
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
title="Start recording (or hold `)"
>
to start
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
title="Compose text (or press T)"
>
text
</KeyHint>
</div>
);
return <ContainerView path={particlePath(networkId, [])} />;
}
+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>
@@ -0,0 +1,269 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CircleCheckBig, CircleDot, FolderIcon } from 'lucide-react';
import type { Particle } from '@/api/types';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import { useAuthStore } from '@/stores/auth-store';
import { useContainerChildren } from '@/hooks/use-container-children';
import { useListKeyboardNav } from '@/hooks/use-list-keyboard-nav';
import {
useCreateParticle,
useCreateStreamParticle,
} from '@/hooks/use-create-particle';
import { ParticleChildrenList } from '@/features/particles/particle-children-list';
import { ComposeOverlay } from '@/features/compose/compose-overlay';
import { ComposeQuotaIndicator } from '@/features/compose/compose-quota-indicator';
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
type ContainerKind = 'stream' | 'folder';
interface ContainerViewProps {
/** Path of the container; network root when it has no segments. */
path: ParticlePath;
/** Present when the container is a folder particle (drives the header). */
folderParticle?: Particle & { type: 'folder' };
}
/**
* Browsable view of a container's children — used for both the network root
* and folders, which share the same structure: a mixed-type child list, the
* compose overlay, and one keyboard grammar. The root is just a folder
* without a doc.
*/
export function ContainerView({ path, folderParticle }: ContainerViewProps) {
const { networkId, segments } = parseParticlePath(path);
const isRoot = segments.length === 0;
const navigate = useNavigate();
const userId = useAuthStore((s) => s.user?.id);
const [composeActive, setComposeActive] = useState(false);
const [creating, setCreating] = useState<ContainerKind | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const statusTab: 'open' | 'closed' =
searchParams.get('status') === 'closed' ? 'closed' : 'open';
const setStatusTab = (next: 'open' | 'closed') => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set('status', next);
return params;
},
{ replace: true },
);
};
// Folders are shelved for now: the root lists streams only, split by
// open/closed status (server-side filter, like before folders). Folder
// containers keep the mixed-type child list so the recursive container
// model can be revived later.
const { items, isLoading, canLoadMore, loadMore } = useContainerChildren(
path,
{ streamStatus: isRoot ? statusTab : undefined },
);
const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle();
const handleOpen = useCallback(
(particleId: string) => {
navigate(`/${networkId}/${[...segments, particleId].join('/')}`);
},
[navigate, networkId, segments],
);
const { selectedIndex } = useListKeyboardNav({
items,
enabled: !composeActive && creating === null,
onOpen: handleOpen,
});
// N creates a stream inside a folder. Root stream creation goes through the
// compose flow.
useEffect(() => {
if (isRoot || composeActive || creating !== null) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
setCreating('stream');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isRoot, composeActive, creating]);
const handleCreateContainer = useCallback(
async (name: string, visibleTo: string[]) => {
if (!userId || !creating) return;
const kind = creating;
setCreating(null);
const id =
kind === 'folder'
? await createParticle.mutateAsync({
path,
type: 'folder',
properties: { name },
createdByHumanId: userId,
visibleTo,
})
: await createStream.mutateAsync({
networkId,
parentPath: path,
properties: { name },
createdByHumanId: userId,
visibleTo,
});
handleOpen(id);
},
[
userId,
creating,
path,
networkId,
createParticle,
createStream,
handleOpen,
],
);
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{isRoot && (
<div className="flex shrink-0 items-center border-b p-1">
<Tabs
value={statusTab}
onValueChange={(v) =>
setStatusTab(v === 'closed' ? 'closed' : 'open')
}
>
<TabsList>
<TabsTrigger value="open">
<CircleDot className="size-3 text-green-500" /> Open
</TabsTrigger>
<TabsTrigger value="closed">
<CircleCheckBig className="size-3" /> Closed
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{folderParticle && (
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<span className="flex size-7 items-center justify-center rounded-md bg-amber-500/15">
<FolderIcon className="size-4 text-amber-500" />
</span>
<h1 className="truncate text-sm font-semibold">
{folderParticle.properties.name}
</h1>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
<ParticleChildrenList
items={items}
networkId={networkId}
isLoading={isLoading}
onOpen={handleOpen}
selectedIndex={selectedIndex}
canLoadMore={canLoadMore}
onLoadMore={loadMore}
emptyMessage={
!isRoot
? 'This folder is empty. Add something using the keyboard shortcuts below.'
: statusTab === 'closed'
? 'No closed streams.'
: 'No streams here. Start a conversation using the keyboard shortcuts below.'
}
/>
</div>
<ComposeOverlay
networkId={networkId}
targetPath={isRoot ? undefined : path}
onActiveChange={setComposeActive}
/>
{!composeActive && (
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
<ComposeQuotaIndicator networkId={networkId} />
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
<div className="pointer-events-auto">
<ContainerControls
isRoot={isRoot}
onCreateStream={() => setCreating('stream')}
/>
</div>
</div>
{creating && (
<ConfigureContainerStep
kind={creating}
networkId={networkId}
onCancel={() => setCreating(null)}
onSubmit={handleCreateContainer}
/>
)}
</div>
);
}
function ContainerControls({
isRoot,
onCreateStream,
}: {
isRoot: boolean;
onCreateStream: () => void;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<KeyHint keys="19">jump</KeyHint>
<VideoAudioToggle />
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
title="Start recording (or hold `)"
>
to start
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
title="Compose text (or press T)"
>
text
</KeyHint>
<KeyHint
keys="D"
onClick={() => requestIntent('task')}
title="Create a task (or press D)"
>
task
</KeyHint>
{!isRoot && (
<KeyHint
keys="N"
onClick={onCreateStream}
title="Create a stream here (or press N)"
>
stream
</KeyHint>
)}
</div>
);
}
@@ -2,21 +2,25 @@ import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
import { softDeleteParticle } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import {
particlePath,
parseParticlePath,
toFirestoreDocPath,
type ParticlePath,
} from '@/lib/particle-path';
import type { Particle } from '@/api/types';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
interface DeleteParticleOverlayProps {
networkId: string;
streamId: string;
/** Path of the stream the particle lives in — may be nested. */
streamPath: ParticlePath;
particle: Particle;
userId: string;
onClose: () => void;
}
export function DeleteParticleOverlay({
networkId,
streamId,
streamPath,
particle,
userId,
onClose,
@@ -29,8 +33,9 @@ export function DeleteParticleOverlay({
if (deleting) return;
setDeleting(true);
try {
const { networkId, segments } = parseParticlePath(streamPath);
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamId, particle.id]),
particlePath(networkId, [...segments, particle.id]),
);
await softDeleteParticle(docPath, userId);
toast.success('Particle deleted');
@@ -41,7 +46,7 @@ export function DeleteParticleOverlay({
toast.error(message);
setDeleting(false);
}
}, [deleting, networkId, onClose, particle.id, streamId, userId]);
}, [deleting, onClose, particle.id, streamPath, userId]);
return (
<ConfirmDestructiveOverlay
@@ -6,17 +6,11 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
FileIcon,
HelpCircleIcon,
ScrollTextIcon,
BookOpenIcon,
} from 'lucide-react';
import { FileIcon, HelpCircleIcon, BookOpenIcon } from 'lucide-react';
import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from '@/lib/humans';
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
quest: { icon: ScrollTextIcon, label: 'Quest' },
paper: { icon: BookOpenIcon, label: 'Paper' },
file: { icon: FileIcon, label: 'File' },
};
@@ -37,19 +31,19 @@ export function FallbackParticleView({
);
const meta = TYPE_META[particle.type] ?? {
icon: HelpCircleIcon,
label: particle.type,
label: particle.type === 'unknown' ? particle.raw_type : particle.type,
};
const Icon = meta.icon;
const title = (() => {
switch (particle.type) {
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'folder':
return particle.properties.name;
case 'unknown':
return 'Not supported in this version of the app';
default:
return null;
}
@@ -1,21 +1,12 @@
import { Particle } from '@/api/types';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import { ComposeOverlay } from '@/features/compose/compose-overlay';
import { type ParticlePath } from '@/lib/particle-path';
import { ContainerView } from '@/features/particles/container-view';
interface FolderViewProps {
folderParticle: Particle;
folderParticle: Particle & { type: 'folder' };
path: ParticlePath;
}
export function FolderView({ path, folderParticle }: FolderViewProps) {
const { networkId } = parseParticlePath(path);
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Folder view {folderParticle.id}
</p>
<ComposeOverlay networkId={networkId} />
</div>
);
return <ContainerView path={path} folderParticle={folderParticle} />;
}
@@ -1,32 +1,19 @@
import {
Fragment,
useMemo,
useRef,
useEffect,
useCallback,
memo,
createElement,
} from 'react';
import { useNavigate } from 'react-router-dom';
import {
Radio,
MessageSquare,
Video,
Mic,
Image,
FileText,
CircleCheck,
StickyNote,
Headphones,
Trash2,
type LucideIcon,
} from 'lucide-react';
import { Headphones, Radio, FolderIcon } 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 { 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';
@@ -36,12 +23,17 @@ import {
type Particle,
type StreamProperties,
} from '@/api/types';
import type { StreamParticle } from '@/hooks/use-stream-particles';
import { useNetwork } from '@/hooks/use-networks';
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
import { useDownloadUrl } from '@/hooks/use-download-url';
import { getMessagePreview, getParticleTypeIcon } from '@/lib/particle-display';
import { StreamContextMenu } from '@/features/particles/stream-context-menu';
type StreamParticle = Particle & {
type: 'stream';
properties: StreamProperties;
};
function VideoThumbnail({
objectId,
isUnseen,
@@ -71,65 +63,16 @@ function VideoThumbnail({
);
}
function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) {
case 'text':
return MessageSquare;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('video/')) return Video;
if (mime.startsWith('audio/')) return Mic;
if (mime.startsWith('image/')) return Image;
return Video;
}
case 'file':
return FileText;
case 'quest':
return CircleCheck;
case 'paper':
return StickyNote;
default:
return Radio;
}
}
function getMessagePreview(particle: Particle): string {
if (isParticleDeleted(particle)) return 'Deleted particle';
switch (particle.type) {
case 'text':
return particle.properties.content;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('image/')) return 'Photo';
if (mime.startsWith('video/') || mime.startsWith('audio/')) {
const transcriptText = particle.properties.transcript?.transcript;
if (transcriptText) return transcriptText;
return mime.startsWith('video/') ? 'Video clip' : 'Voice note';
}
return 'Media';
}
case 'file':
return particle.properties.filename;
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
default:
return particle.type;
}
}
const StreamRow = memo(function StreamRow({
particle,
networkId,
onNavigate,
onOpen,
isSelected,
shortcutKey,
}: {
particle: Particle & { type: 'stream'; properties: StreamProperties };
particle: StreamParticle;
networkId: string;
onNavigate: (streamId: string) => void;
onOpen: (particleId: string) => void;
isSelected?: boolean;
shortcutKey?: number;
}) {
@@ -150,7 +93,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 +101,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 +114,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,
@@ -224,9 +180,9 @@ const StreamRow = memo(function StreamRow({
<div
role="button"
tabIndex={0}
onClick={() => onNavigate(particle.id)}
onClick={() => onOpen(particle.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(particle.id);
if (e.key === 'Enter' || e.key === ' ') onOpen(particle.id);
}}
className={cn(
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
@@ -242,11 +198,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">
@@ -308,34 +265,160 @@ const StreamRow = memo(function StreamRow({
);
});
interface ParticleListViewProps {
streams: StreamParticle[];
const FolderRow = memo(function FolderRow({
particle,
networkId,
onOpen,
isSelected,
shortcutKey,
}: {
particle: Particle & { type: 'folder' };
networkId: string;
onOpen: (particleId: string) => void;
isSelected?: boolean;
shortcutKey?: number;
}) {
const network = useNetwork(networkId);
const creator = resolveHumanDisplay(
particle.created_by_human_id,
network?.humans,
);
return (
<div
role="button"
tabIndex={0}
onClick={() => onOpen(particle.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpen(particle.id);
}}
className={cn(
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
isSelected && 'bg-accent',
)}
>
{shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
{shortcutKey}
</kbd>
)}
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-amber-500/15">
<FolderIcon className="size-4 text-amber-500" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{particle.properties.name}
</p>
<Small className="text-muted-foreground font-normal">
Folder · {creator.displayName}
</Small>
</div>
<Small className="shrink-0 text-muted-foreground">
<RelativeTimestamp date={particle.created_at} />
</Small>
</div>
);
});
const LeafRow = memo(function LeafRow({
particle,
networkId,
onOpen,
isSelected,
shortcutKey,
}: {
particle: Particle;
networkId: string;
onOpen: (particleId: string) => void;
isSelected?: boolean;
shortcutKey?: number;
}) {
const network = useNetwork(networkId);
const creator = resolveHumanDisplay(
particle.created_by_human_id,
network?.humans,
);
const typeIcon = getParticleTypeIcon(particle);
const deleted = isParticleDeleted(particle);
const taskDone = particle.type === 'task' && particle.properties.done;
return (
<div
role="button"
tabIndex={0}
onClick={() => onOpen(particle.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpen(particle.id);
}}
className={cn(
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
isSelected && 'bg-accent',
)}
>
{shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
{shortcutKey}
</kbd>
)}
{createElement(typeIcon, {
className: cn(
'size-4 shrink-0 text-muted-foreground',
taskDone && 'text-emerald-500',
),
})}
<div className="min-w-0 flex-1">
<p
className={cn(
'truncate text-sm',
deleted || taskDone
? 'text-muted-foreground line-through'
: 'text-foreground',
)}
>
{getMessagePreview(particle)}
</p>
<Small className="text-muted-foreground font-normal">
{creator.displayName}
</Small>
</div>
<Small className="shrink-0 text-muted-foreground">
<RelativeTimestamp date={particle.created_at} />
</Small>
</div>
);
});
interface ParticleChildrenListProps {
items: Particle[];
networkId: string;
isLoading: boolean;
/** Open (navigate into / select) a particle by id. */
onOpen: (particleId: string) => void;
selectedIndex?: number | null;
/** Render 19 shortcut badges next to the first nine rows. */
showShortcuts?: boolean;
emptyMessage?: string;
/** When true, render a footer that invokes onLoadMore. */
canLoadMore?: boolean;
onLoadMore?: () => void;
}
/**
* List of stream particles for a container (network root, folder, etc.).
* Browsable list of a container's children, any particle type. Used by the
* network root and folder views.
*/
export function ParticleListView({
streams,
export function ParticleChildrenList({
items,
networkId,
isLoading,
onOpen,
selectedIndex,
showShortcuts = true,
emptyMessage = 'Nothing here yet. Create something using the keyboard shortcuts below.',
canLoadMore,
onLoadMore,
}: ParticleListViewProps) {
const navigate = useNavigate();
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const navigateToStream = useCallback(
(streamId: string) => navigate(`/${networkId}/${streamId}`),
[navigate, networkId],
);
}: ParticleChildrenListProps) {
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
useEffect(() => {
if (
@@ -351,42 +434,52 @@ export function ParticleListView({
return <Progress />;
}
if (streams.length === 0) {
if (items.length === 0) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
<Radio className="text-muted-foreground size-8" />
<p className="text-muted-foreground text-sm">
No streams here. Start a conversation using the keyboard shortcuts
below.
</p>
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
</div>
);
}
return (
<div>
{streams.map((stream, index) => (
<StreamContextMenu
key={stream.id}
particle={stream}
networkId={networkId}
>
{items.map((item, index) => {
const rowProps = {
networkId,
onOpen,
isSelected: index === selectedIndex,
shortcutKey: showShortcuts && index < 9 ? index + 1 : undefined,
};
const row = (
<div
ref={(el) => {
rowRefs.current[index] = el;
}}
>
<StreamRow
particle={stream}
networkId={networkId}
onNavigate={navigateToStream}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
{index < streams.length - 1 && <Separator className="px-4" />}
{item.type === 'stream' ? (
<StreamRow particle={item} {...rowProps} />
) : item.type === 'folder' ? (
<FolderRow particle={item} {...rowProps} />
) : (
<LeafRow particle={item} {...rowProps} />
)}
{index < items.length - 1 && <Separator className="px-4" />}
</div>
</StreamContextMenu>
))}
);
return item.type === 'stream' ? (
<StreamContextMenu
key={item.id}
particle={item}
networkId={networkId}
>
{row}
</StreamContextMenu>
) : (
<Fragment key={item.id}>{row}</Fragment>
);
})}
{canLoadMore && onLoadMore && (
<div className="flex justify-center p-3">
<Button variant="ghost" size="sm" onClick={onLoadMore}>
@@ -5,11 +5,12 @@ import { Skeleton } from '@/components/ui/skeleton';
import {
Video,
Mic,
ScrollText,
CircleCheck,
BookOpen,
FileIcon,
FolderIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export function ParticlePreview({ particle }: { particle: Particle }) {
switch (particle.type) {
@@ -17,8 +18,8 @@ export function ParticlePreview({ particle }: { particle: Particle }) {
return <TextPreview particle={particle} />;
case 'media':
return <MediaPreview particle={particle} />;
case 'quest':
return <QuestPreview particle={particle} />;
case 'task':
return <TaskPreview particle={particle} />;
case 'paper':
return <PaperPreview particle={particle} />;
case 'file':
@@ -128,21 +129,30 @@ function VideoThumbnail({
);
}
function QuestPreview({
function TaskPreview({
particle,
}: {
particle: Extract<Particle, { type: 'quest' }>;
particle: Extract<Particle, { type: 'task' }>;
}) {
const { title, status } = particle.properties;
const { title, done } = particle.properties;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">{title}</p>
{status && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{status}
</span>
)}
<CircleCheck
className={cn(
'h-6 w-6',
done
? 'text-emerald-600/80 dark:text-emerald-400/80'
: 'text-amber-600/70 dark:text-amber-400/70',
)}
/>
<p
className={cn(
'line-clamp-2 text-center text-sm font-medium',
done && 'text-muted-foreground line-through',
)}
>
{title}
</p>
</div>
);
}
@@ -2,12 +2,19 @@ import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Lock } from 'lucide-react';
import { isParticleDeleted, type Particle } from '@/api/types';
import { useLiveParticle } from '@/hooks/use-particle';
import { particlePath } from '@/lib/particle-path';
import { particlePath, type ParticlePath } from '@/lib/particle-path';
import { Button } from '@/components/ui/button';
import Layout from '@/features/layout';
import { StreamView } from '@/features/particles/stream-view';
import { FolderView } from '@/features/particles/folder-view';
import { MediaParticleView } from '@/features/particles/media-particle-view';
import { TextParticleView } from '@/features/particles/text-particle-view';
import { TaskParticleView } from '@/features/particles/task-particle-view';
import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
/**
* Route-level component for /:networkId/*.
@@ -25,9 +32,11 @@ export default function ParticleViewResolver() {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
<Layout>
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
</Layout>
);
}
@@ -35,25 +44,106 @@ export default function ParticleViewResolver() {
// Errors here are almost always Firestore permission-denied — the user lost
// access to the network or to a custom-visibility particle. The React Router
// stays on the dead route, so without an explicit escape the user is stuck.
return <InaccessibleParticle />;
return (
<Layout>
<InaccessibleParticle />
</Layout>
);
}
// Streams render their own full-screen chrome; folders and leaves live
// inside the app Layout (breadcrumbs, full-height column) like the root.
switch (particle.type) {
case 'stream':
return <StreamView streamParticle={particle} path={path} />;
case 'folder':
return <FolderView folderParticle={particle} path={path} />;
return (
<Layout>
<FolderView folderParticle={particle} path={path} />
</Layout>
);
default:
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
{particle.type} particle: {particle.id}
</p>
</div>
<Layout>
<LeafParticleView
particle={particle}
containerPath={particlePath(networkId, segments.slice(0, -1))}
networkId={networkId}
/>
</Layout>
);
}
}
const noop = () => {};
/**
* Standalone view for a leaf particle opened directly (e.g. from a folder),
* outside any stream playback: renders the particle's native view with no
* auto-advance.
*/
function LeafParticleView({
particle,
containerPath,
networkId,
}: {
particle: Particle;
containerPath: ParticlePath;
networkId: string;
}) {
const content = (() => {
if (isParticleDeleted(particle)) {
return (
<DeletedParticleView
particle={particle}
networkId={networkId}
paused
onEnded={noop}
/>
);
}
switch (particle.type) {
case 'media':
return (
<MediaParticleView
particle={particle}
streamPath={containerPath}
paused={false}
onEnded={noop}
/>
);
case 'text':
return (
<TextParticleView
particle={particle}
streamPath={containerPath}
paused
onEnded={noop}
/>
);
case 'task':
return (
<TaskParticleView
particle={particle}
containerPath={containerPath}
paused
onEnded={noop}
/>
);
default:
return (
<FallbackParticleView particle={particle} networkId={networkId} />
);
}
})();
return (
<div className="min-h-0 flex-1 bg-black text-white [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
{content}
</div>
);
}
function InaccessibleParticle() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -1,4 +1,4 @@
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import {
Tooltip,
TooltipContent,
@@ -7,58 +7,87 @@ import {
import type { HumanPresence } from '@/hooks/use-presence-positions';
const MAX_VISIBLE_AVATARS = 3;
const PAGE_SIZE = 10;
const VISIBLE_SEGMENTS = 10;
interface PlaybackPageIndicatorProps {
total: number;
/** Number of particles currently loaded in the window. */
loadedCount: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment?: Map<number, HumanPresence[]>;
/** Set of humanIds currently online in the stream channel. */
onlineHumanIds?: Set<string>;
/** More (older) particles exist before the loaded window. */
hasMoreOlder?: boolean;
/** Pull in older history when scrubbing past the oldest loaded segment. */
onLoadOlder?: () => void;
/** Render only avatars or only tracks. Omit to render both. */
layer?: 'avatars' | 'tracks';
}
/**
* A streaming, count-free progress scrubber. The stream is paginated, so the
* true particle count is unknown — instead this shows a sliding window of
* segments around the current position. Segments map to the loaded particles
* (newest on the right); the edge stubs scrub within the window and pull in
* older history when you reach the oldest loaded segment.
*/
export function PlaybackPageIndicator({
total,
loadedCount,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
hasMoreOlder,
onLoadOlder,
layer,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
if (loadedCount === 0) return null;
const showAvatars = layer !== 'tracks';
const showTracks = layer !== 'avatars';
const paginated = total > PAGE_SIZE;
const safeCurrent = current < 0 ? 0 : current;
const pageStart = paginated
? Math.floor(safeCurrent / PAGE_SIZE) * PAGE_SIZE
: 0;
const visibleCount = paginated
? Math.min(PAGE_SIZE, total - pageStart)
: total;
const hasPrevPage = paginated && pageStart > 0;
const hasNextPage = paginated && pageStart + PAGE_SIZE < total;
// Slide the visible window so the current segment stays in view with a bit of
// context on either side, clamped to the loaded range.
const sliceStart = Math.min(
Math.max(0, safeCurrent - Math.floor(VISIBLE_SEGMENTS / 2)),
Math.max(0, loadedCount - VISIBLE_SEGMENTS),
);
const sliceEnd = Math.min(loadedCount, sliceStart + VISIBLE_SEGMENTS);
const visibleCount = sliceEnd - sliceStart;
const paginated = loadedCount > VISIBLE_SEGMENTS || !!hasMoreOlder;
// Older = lower indices (left); newer = higher indices (right).
const hasOlder = sliceStart > 0 || !!hasMoreOlder;
const hasNewer = sliceEnd < loadedCount;
// Stubs jump a page at a time; reaching the oldest loaded pulls in history.
const goOlder = () => {
const target = safeCurrent - VISIBLE_SEGMENTS;
if (target >= 0) onGoTo(target);
else if (sliceStart > 0) onGoTo(0);
else if (hasMoreOlder) onLoadOlder?.();
};
const goNewer = () => {
onGoTo(Math.min(loadedCount - 1, safeCurrent + VISIBLE_SEGMENTS));
};
return (
<div className="flex w-full flex-col items-stretch leading-none">
<div className="flex w-full items-end gap-px">
{paginated && (
<GhostStub
visible={hasPrevPage}
visible={hasOlder}
interactive={showTracks}
onClick={() => onGoTo(pageStart - 1)}
onClick={goOlder}
/>
)}
<div className="flex flex-1 items-end gap-px">
{Array.from({ length: visibleCount }, (_, j) => {
const i = pageStart + j;
const i = sliceStart + j;
const presence = presenceBySegment?.get(i);
return (
<div key={i} className="flex flex-1 flex-col items-stretch">
@@ -100,17 +129,12 @@ export function PlaybackPageIndicator({
</div>
{paginated && (
<GhostStub
visible={hasNextPage}
visible={hasNewer}
interactive={showTracks}
onClick={() => onGoTo(pageStart + PAGE_SIZE)}
onClick={goNewer}
/>
)}
</div>
{paginated && showTracks && current >= 0 && (
<div className="pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide text-white/40">
{current + 1} / {total}
</div>
)}
</div>
);
}
@@ -161,18 +185,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">
@@ -4,18 +4,18 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { updateParticleProperties } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
import type { Particle } from '@/api/types';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
interface RenameStreamOverlayProps {
networkId: string;
streamPath: ParticlePath;
streamParticle: Particle & { type: 'stream' };
onClose: () => void;
}
export function RenameStreamOverlay({
networkId,
streamPath,
streamParticle,
onClose,
}: RenameStreamOverlayProps) {
@@ -32,15 +32,13 @@ export function RenameStreamOverlay({
if (!canSave) return;
setSaving(true);
try {
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id]),
);
const docPath = toFirestoreDocPath(streamPath);
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
onClose();
} finally {
setSaving(false);
}
}, [canSave, networkId, onClose, streamParticle.id, trimmed]);
}, [canSave, streamPath, onClose, trimmed]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -0,0 +1,149 @@
import { cn } from '@/lib/utils';
import { PlaybackPageIndicator } from '@/features/particles/playback-page-indicator';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import type { HumanPresence } from '@/hooks/use-presence-positions';
export function BottomBar({
visible,
loadedCount,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
hasMoreOlder,
onLoadOlder,
exitRemainingMs,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
visible: boolean;
loadedCount: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment: Map<number, HumanPresence[]>;
onlineHumanIds: Set<string>;
hasMoreOlder: boolean;
onLoadOlder: () => void;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
return (
<div
className={cn(
'absolute inset-x-0 bottom-0 z-10 transition-all duration-300',
visible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-2 pointer-events-none',
)}
>
{/* Presence avatars — above the blurred background */}
<PlaybackPageIndicator
loadedCount={loadedCount}
current={current}
progress={progress}
onGoTo={onGoTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
hasMoreOlder={hasMoreOlder}
onLoadOlder={onLoadOlder}
layer="avatars"
/>
{/* Blurred background container — tracks + controls */}
<div className="pb-3">
<PlaybackPageIndicator
loadedCount={loadedCount}
current={current}
progress={progress}
onGoTo={onGoTo}
hasMoreOlder={hasMoreOlder}
onLoadOlder={onLoadOlder}
layer="tracks"
/>
<div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && (
<div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
</div>
)}
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
onOpenHuddle={onOpenHuddle}
onExit={onExit}
/>
</div>
</div>
</div>
);
}
export function StreamViewControls({
showEscape,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
<KeyHint
keys="Esc"
onClick={onExit}
title="Back to network (or press Esc)"
>
back
</KeyHint>
)}
<VideoAudioToggle />
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
title="Reply with a recording (or hold `)"
>
to reply
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
title="Reply with text (or press T)"
>
text
</KeyHint>
<KeyHint
keys="D"
onClick={() => requestIntent('task')}
title="Add a task (or press D)"
>
task
</KeyHint>
<KeyHint
keys="H"
onClick={onOpenHuddle}
title="Start a huddle (or press H)"
>
huddle
</KeyHint>
<KeyHint
keys="?"
onClick={onOpenKeybindings}
title="Show all shortcuts"
aria-label="Show keyboard shortcuts"
/>
</div>
);
}
@@ -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>
);
},
);
@@ -7,10 +7,10 @@ import {
import { CircleCheckBig, CircleDot } from 'lucide-react';
import { updateStreamStatus } from '@/lib/firestore-particles';
import { toFirestoreDocPath, particlePath } from '@/lib/particle-path';
import type { StreamParticle } from '@/hooks/use-stream-particles';
import { isStreamOpen, type Particle } from '@/api/types';
interface StreamContextMenuProps {
particle: StreamParticle;
particle: Extract<Particle, { type: 'stream' }>;
networkId: string;
children: React.ReactNode;
}
@@ -20,7 +20,7 @@ export function StreamContextMenu({
networkId,
children,
}: StreamContextMenuProps) {
const isOpen = particle.status === 'open';
const isOpen = isStreamOpen(particle);
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
const toggleStatus = async () => {
@@ -0,0 +1,289 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import {
CircleCheck,
FileText,
Image,
List,
Loader2,
Mic,
Video,
} from 'lucide-react';
import { isParticleDeleted, type Human, type Particle } from '@/api/types';
import { cn } from '@/lib/utils';
import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from '@/lib/humans';
import { RelativeTimestamp } from '@/components/relative-timestamp';
import { HumanAvatar } from '@/components/human-avatar';
import { KeyHint } from '@/components/key-hint';
import { ScrollArea } from '@/components/ui/scroll-area';
interface StreamListSidebarProps {
items: Particle[];
networkId: string;
currentIndex: number;
onSelect: (index: number) => void;
onToggle: () => void;
/** More (older) particles exist before the loaded window. */
hasMoreOlder: boolean;
/** Load the next page of older particles. */
onLoadOlder: () => void;
isLoadingOlder: boolean;
}
/**
* Browse-mode panel beside the stream: a chat-like timeline of the loaded
* particles. Selecting a message plays it in the immersive stream view;
* nothing auto-advances. Older history is fetched automatically as the user
* scrolls toward the top.
*/
export function StreamListSidebar({
items,
networkId,
currentIndex,
onSelect,
onToggle,
hasMoreOlder,
onLoadOlder,
isLoadingOlder,
}: StreamListSidebarProps) {
const network = useNetwork(networkId);
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
const viewportRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
// Scroll into view only when the selection genuinely changes — not when the
// current index shifts because older particles were prepended.
const selectedId = currentIndex >= 0 ? items[currentIndex]?.id : undefined;
const prevSelectedIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (selectedId && selectedId !== prevSelectedIdRef.current) {
rowRefs.current[currentIndex]?.scrollIntoView({ block: 'nearest' });
}
prevSelectedIdRef.current = selectedId;
}, [selectedId, currentIndex]);
// Anchor the viewport when older particles are prepended so the content the
// user is looking at stays put instead of jumping.
const pendingAnchorRef = useRef<{ height: number; top: number } | null>(null);
const requestOlder = useCallback(() => {
const vp = viewportRef.current;
if (!vp) return;
pendingAnchorRef.current = { height: vp.scrollHeight, top: vp.scrollTop };
onLoadOlder();
}, [onLoadOlder]);
useLayoutEffect(() => {
const vp = viewportRef.current;
const anchor = pendingAnchorRef.current;
if (!vp || !anchor) return;
const delta = vp.scrollHeight - anchor.height;
if (delta > 0) vp.scrollTop = anchor.top + delta;
pendingAnchorRef.current = null;
}, [items]);
// Auto-fetch older history when the top sentinel scrolls into view.
useEffect(() => {
const vp = viewportRef.current;
const sentinel = sentinelRef.current;
if (!vp || !sentinel || !hasMoreOlder) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isLoadingOlder) requestOlder();
},
{ root: vp, rootMargin: '120px 0px 0px 0px' },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMoreOlder, isLoadingOlder, requestOlder]);
return (
<aside className="dark flex w-60 shrink-0 flex-col overflow-hidden border-l border-white/10 bg-zinc-950">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3">
<List className="size-3.5 text-white/40" />
<span className="truncate text-sm font-medium text-white/90 mr-auto">
{items.length}
{hasMoreOlder ? '+' : ''} messages
</span>
<KeyHint
keys="L"
onClick={onToggle}
title="Hide list (or press L)"
className="text-xs text-white/40"
>
to close
</KeyHint>
</div>
<ScrollArea viewportRef={viewportRef} className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 px-2 py-2">
<div ref={sentinelRef} aria-hidden />
{hasMoreOlder && (
<div className="flex items-center justify-center py-2 text-white/40">
<Loader2 className="size-3.5 animate-spin" />
</div>
)}
{items.map((item, index) => (
<div
key={item.id}
ref={(el) => {
rowRefs.current[index] = el;
}}
>
<ChatRow
particle={item}
humans={network?.humans}
isSelected={index === currentIndex}
onClick={() => onSelect(index)}
/>
</div>
))}
{items.length === 0 && (
<p className="px-2 py-8 text-center text-sm text-white/40">
No particles in this stream yet.
</p>
)}
</div>
</ScrollArea>
</aside>
);
}
function ChatRow({
particle,
humans,
isSelected,
onClick,
}: {
particle: Particle;
humans: Human[] | undefined;
isSelected: boolean;
onClick: () => void;
}) {
const sender = resolveHumanDisplay(particle.created_by_human_id, humans);
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
// Enter only — Space is reserved for play/pause in the stream view.
onKeyDown={(e) => {
if (e.key === 'Enter') onClick();
}}
className={cn(
'flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
isSelected ? 'bg-white/10' : 'hover:bg-white/5',
)}
>
<HumanAvatar
size="sm"
className="mt-0.5 shrink-0"
initials={sender.initials}
avatarObjectId={sender.avatarObjectId}
fallbackClassName="bg-white/10 text-white/80 text-[10px] font-medium"
/>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-xs font-semibold text-white/90">
{sender.displayName}
</span>
<span className="shrink-0 text-[10px] text-white/35">
<RelativeTimestamp date={particle.created_at} />
</span>
</div>
<ChatRowContent particle={particle} />
</div>
</div>
);
}
function ChatRowContent({ particle }: { particle: Particle }) {
if (isParticleDeleted(particle)) {
return (
<p className="text-xs italic text-white/35">This particle was deleted</p>
);
}
switch (particle.type) {
case 'text':
return (
<p className="line-clamp-2 text-xs leading-relaxed [overflow-wrap:anywhere] whitespace-pre-line text-white/70">
{particle.properties.content}
</p>
);
case 'media': {
const mime = particle.properties.mime_type;
const transcript = particle.properties.transcript?.transcript;
const isVideo = mime.startsWith('video/');
const isAudio = mime.startsWith('audio/');
const isImage = mime.startsWith('image/');
const Icon = isVideo ? Video : isAudio ? Mic : isImage ? Image : Video;
const label = isVideo
? 'Video clip'
: isAudio
? 'Voice note'
: isImage
? 'Photo'
: 'Media';
const durationSec = Math.round(particle.properties.duration_ms / 1000);
const duration =
durationSec > 0
? ` · ${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`
: '';
return (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs text-white/70">
<Icon className="size-3.5 shrink-0 text-white/50" />
{label}
{duration}
</span>
{transcript && (
<p className="line-clamp-2 text-xs leading-relaxed text-white/45">
{transcript}
</p>
)}
</div>
);
}
case 'file':
return (
<span className="flex items-center gap-1.5 text-xs text-white/70">
<FileText className="size-3.5 shrink-0 text-white/50" />
<span className="truncate">{particle.properties.filename}</span>
</span>
);
case 'task': {
const { title, done, checklist = [] } = particle.properties;
const doneCount = checklist.filter((item) => item.done).length;
return (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs text-white/70">
<CircleCheck
className={cn(
'size-3.5 shrink-0',
done ? 'text-emerald-400' : 'text-white/50',
)}
/>
<span
className={cn('truncate', done && 'text-white/40 line-through')}
>
{title}
</span>
</span>
{checklist.length > 0 && (
<span className="pl-5 text-[10px] text-white/40">
{doneCount} / {checklist.length} subtasks
</span>
)}
</div>
);
}
case 'paper':
return (
<p className="truncate text-xs text-white/70">
{particle.properties.title}
</p>
);
default:
return <p className="text-xs text-white/45">{particle.type}</p>;
}
}
@@ -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 {
@@ -10,7 +10,7 @@ import {
parseVisibleTo,
} from '@/lib/stream-visibility';
import { updateParticleVisibleTo } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
import { useNetwork } from '@/hooks/use-networks';
import { cn, getInitials } from '@/lib/utils';
import { resolveHumanDisplay } from '@/lib/humans';
@@ -19,6 +19,7 @@ import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
interface StreamMembersOverlayProps {
networkId: string;
streamPath: ParticlePath;
streamParticle: Particle & { type: 'stream' };
isCreator: boolean;
onClose: () => void;
@@ -26,6 +27,7 @@ interface StreamMembersOverlayProps {
export function StreamMembersOverlay({
networkId,
streamPath,
streamParticle,
isCreator,
onClose,
@@ -40,10 +42,7 @@ export function StreamMembersOverlay({
[streamParticle.visible_to, networkId],
);
const docPath = useMemo(
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
[networkId, streamParticle.id],
);
const docPath = useMemo(() => toFirestoreDocPath(streamPath), [streamPath]);
const memberIds =
visibility.mode === 'network'
@@ -170,11 +169,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 +225,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>
@@ -1,10 +1,9 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
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 { isParticleDeleted, isStreamOpen, type Particle } from '@/api/types';
import { AvatarGroup } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import {
Tooltip,
TooltipContent,
@@ -19,20 +18,21 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Settings,
CircleCheckBig,
CircleDot,
EllipsisVertical,
Pencil,
Lock,
Globe,
Trash2,
CircleCheckBig,
CircleDot,
} from 'lucide-react';
import { updateStreamStatus } from '@/lib/firestore-particles';
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay';
import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay';
import { parseVisibleTo } from '@/lib/stream-visibility';
import { getParticleDisplayName } from '@/lib/particle-display';
import {
Breadcrumb,
BreadcrumbItem,
@@ -47,32 +47,20 @@ import { resolveHumanDisplay } from '@/lib/humans';
import { platform } from '@/lib/platform';
import { requireDesktop } from '@/lib/platform/desktop-only';
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'text':
return particle.properties.content.slice(0, 30);
case 'media':
return particle.type;
}
}
interface TopBarProps {
networkId: string;
particle: Particle | null;
streamParticle: Particle & { type: 'stream' };
/** Resolved path of the stream — may be nested under a container. */
streamPath: ParticlePath;
}
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
const navigate = useNavigate();
export function TopBar({
networkId,
particle,
streamParticle,
streamPath,
}: TopBarProps) {
const network = useNetwork(networkId);
const userId = useAuthStore((s) => s.user?.id);
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
@@ -147,11 +135,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>
@@ -162,8 +151,8 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
</button>
)}
{streamParticle.status === 'closed' && (
<span className="no-drag flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-muted-foreground backdrop-blur-sm">
{!isStreamOpen(streamParticle) && (
<span className="no-drag text-muted-foreground flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs backdrop-blur-sm">
<CircleCheckBig className="size-3" />
Closed
</span>
@@ -186,18 +175,22 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isCreator && (
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
<Pencil className="size-4" />
Rename stream
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={async () => {
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id]),
);
const docPath = toFirestoreDocPath(streamPath);
await updateStreamStatus(
docPath,
streamParticle.status === 'open' ? 'closed' : 'open',
isStreamOpen(streamParticle) ? 'closed' : 'open',
);
}}
>
{streamParticle.status === 'open' ? (
{isStreamOpen(streamParticle) ? (
<>
<CircleCheckBig className="size-4" />
Close stream
@@ -209,12 +202,6 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
</>
)}
</DropdownMenuItem>
{isCreator && (
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
<Pencil className="size-4" />
Rename stream
</DropdownMenuItem>
)}
{canDeleteParticle && (
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
@@ -224,16 +211,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
Delete particle
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => navigate('/settings')}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{renameOpen && isCreator && (
<RenameStreamOverlay
networkId={networkId}
streamPath={streamPath}
streamParticle={streamParticle}
onClose={() => setRenameOpen(false)}
/>
@@ -241,8 +224,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
{deleteOpen && canDeleteParticle && particle && userId && (
<DeleteParticleOverlay
networkId={networkId}
streamId={streamParticle.id}
streamPath={streamPath}
particle={particle}
userId={userId}
onClose={() => setDeleteOpen(false)}
@@ -252,6 +234,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
{membersOpen && (
<StreamMembersOverlay
networkId={networkId}
streamPath={streamPath}
streamParticle={streamParticle}
isCreator={isCreator}
onClose={() => setMembersOpen(false)}
@@ -313,11 +296,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 +340,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>
);
+174 -241
View File
@@ -6,6 +6,7 @@ import {
useRef,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { Play } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/api/client';
import { isParticleDeleted, type Particle } from '@/api/types';
@@ -19,17 +20,20 @@ import {
ComposeOverlay,
type ComposeStep,
} from '@/features/compose/compose-overlay';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { PlaybackPageIndicator } from '@/features/particles/playback-page-indicator';
import {
MediaParticleView,
type MediaParticleHandle,
} from '@/features/particles/media-particle-view';
import { TextParticleView } from '@/features/particles/text-particle-view';
import { TaskParticleView } from '@/features/particles/task-particle-view';
import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import {
BottomBar,
StreamViewControls,
} from '@/features/particles/stream-bottom-bar';
import { StreamListSidebar } from '@/features/particles/stream-list-sidebar';
import { useStreamViewMode } from '@/hooks/use-stream-view-mode';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
import {
KeybindingsOverlay,
@@ -51,7 +55,6 @@ import {
type ComposingMode,
} from '@/features/particles/stream-presence-context';
import { ComposingIndicator } from '@/components/composing-indicator';
import { cn } from '@/lib/utils';
import { useMount } from 'react-use';
import {
usePlaybackPauseStore,
@@ -63,11 +66,29 @@ import { useStreamActionKeys } from '@/hooks/use-stream-action-keys';
import { platform } from '@/lib/platform';
import { requireDesktop } from '@/lib/platform/desktop-only';
const noop = () => {};
// Compose step → the composing-presence mode broadcast to other viewers.
const STEP_TO_COMPOSING_MODE: Record<ComposeStep, ComposingMode | null> = {
idle: null,
submitting: null,
recording: 'recording',
typing: 'typing',
task: 'typing',
reviewing: 'typing',
configuring: 'typing',
picking: 'screen',
};
function getReactions(
particle: Particle,
): Record<string, string[]> | undefined {
if (isParticleDeleted(particle)) return undefined;
if (particle.type === 'media' || particle.type === 'text')
if (
particle.type === 'media' ||
particle.type === 'text' ||
particle.type === 'task'
)
return particle.reactions;
return undefined;
}
@@ -134,6 +155,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
bindings: [
{ keys: ['←', '→', '↑', '↓'], description: 'Previous / next particle' },
{ keys: ['Esc'], description: 'Back to network' },
{ keys: ['L'], description: 'Toggle list view' },
],
},
{
@@ -151,6 +173,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
{ keys: ['Hold', '`'], description: 'Reply' },
{ keys: ['S'], description: 'Screen record' },
{ keys: ['T'], description: 'Text compose' },
{ keys: ['D'], description: 'New task' },
{ keys: ['V'], description: 'Toggle video / audio' },
{ keys: ['H'], description: 'Join huddle' },
],
@@ -189,16 +212,23 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
platform.autoplay.dismiss();
});
const { mode, toggle: toggleViewMode } = useStreamViewMode();
const {
children,
currentParticle,
currentIndex,
status,
hasMoreOlder,
loadOlder,
isLoadingOlder,
next,
prev,
goTo,
goToParticle,
} = useStreamPlayback(streamParticle, path);
} = useStreamPlayback(streamParticle, path, {
autoAdvanceOnNew: mode === 'player',
});
usePrefetchAdjacentMedia(children, currentIndex);
@@ -258,7 +288,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
[handleToggleReaction],
);
const { fastPlayback } = usePlaybackKeys({ mediaRef });
const { fastPlayback, spacePaused, resume } = usePlaybackKeys({ mediaRef });
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
@@ -271,6 +301,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
childrenLength: children.length,
mediaRef,
onExit: handleExitNavigate,
onToggleViewMode: toggleViewMode,
});
const handleOpenHuddle = useCallback(() => {
@@ -301,16 +332,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
// Broadcast composing state to other viewers
useEffect(() => {
const stepToMode: Record<string, ComposingMode | null> = {
idle: null,
submitting: null,
recording: 'recording',
typing: 'typing',
reviewing: 'typing',
configuring: 'typing',
picking: 'screen',
};
const mode = stepToMode[composeStep] ?? null;
const mode = STEP_TO_COMPOSING_MODE[composeStep] ?? null;
if (mode) {
startComposing(mode);
} else {
@@ -331,7 +353,12 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
// Always show controls when compose is active or exit countdown is visible
const controlsVisible = showControls || composeActive || status === 'ended';
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
// No auto-exit while browsing in list mode.
const exitRemainingMs = useExitCountdown(
status,
paused || mode === 'list',
handleExitNavigate,
);
// Reset progress when the particle changes.
if (currentParticle?.id !== prevParticleId) {
@@ -371,14 +398,15 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
disabled={streamParticle.status === 'closed'}
onParticleCreated={handleParticleCreated}
/>
</div>
);
}
// Render particle content inline
// Render particle content inline. In list mode the selected particle still
// plays, but nothing chains: reaching the end doesn't advance.
const handleParticleEnded = mode === 'player' ? next : noop;
function renderParticle(particle: Particle) {
if (isParticleDeleted(particle)) {
return (
@@ -387,7 +415,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
particle={particle}
networkId={networkId}
paused={paused}
onEnded={next}
onEnded={handleParticleEnded}
/>
);
}
@@ -400,7 +428,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onEnded={handleParticleEnded}
onProgress={setProgress}
/>
);
@@ -411,7 +439,18 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onEnded={handleParticleEnded}
onProgress={setProgress}
/>
);
case 'task':
return (
<TaskParticleView
key={particle.id}
particle={particle}
containerPath={path}
paused={paused}
onEnded={handleParticleEnded}
onProgress={setProgress}
/>
);
@@ -423,233 +462,127 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
}
return (
<div
className="relative flex h-screen flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)}
>
{/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
<div className="flex h-screen overflow-hidden bg-black">
{/* Stream chrome — immersive playback column */}
<div
className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)}
>
{/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2">
<TopBar
networkId={networkId}
particle={currentParticle}
streamParticle={streamParticle}
/>
</div>
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div className="relative flex h-full w-full cursor-pointer items-center justify-center">
{renderParticle(currentParticle)}
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
{fastPlayback && (
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
1.5x
</div>
)}
{paused && (
<div className="rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm">
Paused
</div>
)}
</div>
</div>
)}
</div>
{/* Reaction bar — always visible */}
{currentParticle && !isParticleDeleted(currentParticle) && (
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
<ReactionBar
reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ''}
humans={network?.humans}
onToggle={handleToggleReaction}
onOpenTextReaction={() => setTextReactionOpen(true)}
/>
<TextReactionInput
open={textReactionOpen}
onSubmit={handleSubmitTextReaction}
onClose={() => setTextReactionOpen(false)}
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2">
<TopBar
networkId={networkId}
particle={currentParticle}
streamParticle={streamParticle}
streamPath={path}
/>
</div>
)}
{/* Composing indicator — left edge, always visible */}
<ComposingIndicator
users={composingUsers}
networkHumans={network?.humans}
/>
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div className="relative flex h-full w-full cursor-pointer items-center justify-center">
{renderParticle(currentParticle)}
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onStepChange={setComposeStep}
disabled={streamParticle.status === 'closed'}
onParticleCreated={handleParticleCreated}
/>
{/* Bottom gradient safe zone for keyboard hints */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
{/* BottomBar */}
<BottomBar
visible={controlsVisible}
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/>
<KeybindingsOverlay
open={showKeybindings}
onClose={() => setShowKeybindings(false)}
groups={STREAM_VIEW_KEYBINDINGS}
title="Stream View"
/>
</div>
);
}
function BottomBar({
visible,
total,
current,
progress,
onGoTo,
presenceBySegment,
onlineHumanIds,
exitRemainingMs,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
visible: boolean;
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment: Map<
number,
import('@/hooks/use-presence-positions').HumanPresence[]
>;
onlineHumanIds: Set<string>;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
return (
<div
className={cn(
'absolute inset-x-0 bottom-0 z-10 transition-all duration-300',
visible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-2 pointer-events-none',
)}
>
{/* Presence avatars — above the blurred background */}
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
layer="avatars"
/>
{/* Blurred background container — tracks + controls */}
<div className="pb-3">
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
layer="tracks"
/>
<div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && (
<div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
{fastPlayback && (
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
1.5x
</div>
)}
{spacePaused && (
<button
type="button"
onClick={resume}
title="Resume (or press Space)"
className="pointer-events-auto flex items-center gap-1.5 rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
>
<Play className="size-3 fill-current" />
Paused
</button>
)}
</div>
</div>
)}
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
onOpenHuddle={onOpenHuddle}
onExit={onExit}
/>
</div>
</div>
</div>
);
}
function StreamViewControls({
showEscape,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
<KeyHint
keys="Esc"
onClick={onExit}
title="Back to network (or press Esc)"
>
back
</KeyHint>
{/* Reaction bar — always visible */}
{currentParticle && !isParticleDeleted(currentParticle) && (
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
<ReactionBar
reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ''}
humans={network?.humans}
onToggle={handleToggleReaction}
onOpenTextReaction={() => setTextReactionOpen(true)}
/>
<TextReactionInput
open={textReactionOpen}
onSubmit={handleSubmitTextReaction}
onClose={() => setTextReactionOpen(false)}
/>
</div>
)}
{/* Composing indicator — left edge, always visible */}
<ComposingIndicator
users={composingUsers}
networkHumans={network?.humans}
/>
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onStepChange={setComposeStep}
onParticleCreated={handleParticleCreated}
/>
{/* Bottom gradient safe zone for keyboard hints */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
{/* BottomBar — pinned visible while browsing, mouse-activity in player */}
<BottomBar
visible={mode === 'list' || controlsVisible}
loadedCount={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
hasMoreOlder={hasMoreOlder}
onLoadOlder={loadOlder}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/>
<KeybindingsOverlay
open={showKeybindings}
onClose={() => setShowKeybindings(false)}
groups={STREAM_VIEW_KEYBINDINGS}
title="Stream View"
/>
</div>
{/* Browse sidebar — a separate chat-like panel beside the stream */}
{mode === 'list' && (
<StreamListSidebar
items={children}
networkId={networkId}
currentIndex={currentIndex}
onSelect={goTo}
onToggle={toggleViewMode}
hasMoreOlder={hasMoreOlder}
onLoadOlder={loadOlder}
isLoadingOlder={isLoadingOlder}
/>
)}
<VideoAudioToggle />
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
title="Reply with a recording (or hold `)"
>
to reply
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
title="Reply with text (or press T)"
>
text
</KeyHint>
<KeyHint
keys="H"
onClick={onOpenHuddle}
title="Start a huddle (or press H)"
>
huddle
</KeyHint>
<KeyHint
keys="?"
onClick={onOpenKeybindings}
title="Show all shortcuts"
aria-label="Show keyboard shortcuts"
/>
</div>
);
}
@@ -0,0 +1,335 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { deleteField } from 'firebase/firestore';
import { Plus, X } from 'lucide-react';
import type { ChecklistItem, Particle } from '@/api/types';
import {
particlePath,
parseParticlePath,
toFirestoreDocPath,
type ParticlePath,
} from '@/lib/particle-path';
import {
updateParticle,
updateParticleProperties,
} from '@/lib/firestore-particles';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { HumanAvatar } from '@/components/human-avatar';
import { useNetwork } from '@/hooks/use-networks';
import { useFixedDwell } from '@/hooks/use-fixed-dwell';
import { useLiveDraftField } from '@/hooks/use-live-draft-field';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { resolveHumanDisplay } from '@/lib/humans';
type TaskParticle = Extract<Particle, { type: 'task' }>;
interface TaskParticleViewProps {
particle: TaskParticle;
containerPath: ParticlePath;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
const DWELL_DURATION_S = 8;
const UNASSIGNED = 'unassigned';
function useParticleDocPath(
containerPath: ParticlePath,
particleId: string,
): string {
const { networkId, segments } = parseParticlePath(containerPath);
return toFirestoreDocPath(particlePath(networkId, [...segments, particleId]));
}
export function TaskParticleView({
particle,
containerPath,
paused,
onEnded,
onProgress,
}: TaskParticleViewProps) {
const { networkId } = parseParticlePath(containerPath);
const network = useNetwork(networkId);
const docPath = useParticleDocPath(containerPath, particle.id);
const {
title,
notes,
checklist = [],
assigned_to,
done,
} = particle.properties;
// Suspend playback while any field inside the card has focus so typing
// doesn't race the dwell timer or get eaten by global key handlers.
const [editing, setEditing] = useState(false);
useSuspendPlayback(editing, `task-edit-${particle.id}`);
useFixedDwell({
id: particle.id,
durationS: DWELL_DURATION_S,
paused,
onEnded,
onProgress,
});
const titleField = useLiveDraftField({
remoteValue: title,
commit: (value) =>
updateParticleProperties<'task'>(docPath, { title: value }),
});
const notesField = useLiveDraftField({
remoteValue: notes ?? '',
commit: (value) =>
updateParticleProperties<'task'>(docPath, { notes: value }),
});
// Checklist writes replace the whole array (merged against the latest live
// value); concurrent edits to the same checklist are last-write-wins.
const checklistRef = useRef(checklist);
useEffect(() => {
checklistRef.current = checklist;
}, [checklist]);
const writeChecklist = useCallback(
(items: ChecklistItem[]) => {
// Advance the local base before the write so a second edit issued before
// the next snapshot composes on top of this one instead of dropping it.
checklistRef.current = items;
return updateParticleProperties<'task'>(docPath, { checklist: items });
},
[docPath],
);
const handleToggleDone = useCallback(
(checked: boolean) =>
updateParticleProperties<'task'>(docPath, { done: checked }),
[docPath],
);
const handleToggleItem = useCallback(
(index: number, checked: boolean) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, done: checked } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleCommitItemText = useCallback(
(index: number, text: string) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, text } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleRemoveItem = useCallback(
(index: number) => {
void writeChecklist(checklistRef.current.filter((_, i) => i !== index));
},
[writeChecklist],
);
const handleAddItem = useCallback(
(text: string) => {
void writeChecklist([...checklistRef.current, { text, done: false }]);
},
[writeChecklist],
);
const handleAssign = useCallback(
(value: string) => {
if (value === UNASSIGNED) {
void updateParticle(docPath, 'properties.assigned_to', deleteField());
} else {
void updateParticleProperties<'task'>(docPath, { assigned_to: value });
}
},
[docPath],
);
const assignee = resolveHumanDisplay(assigned_to, network?.humans);
const doneCount = checklist.filter((item) => item.done).length;
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div
className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md"
onFocusCapture={() => setEditing(true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
}}
>
<div className="flex items-start gap-3">
<Checkbox
checked={done}
onCheckedChange={(checked) => handleToggleDone(checked === true)}
className="mt-1.5 size-5 rounded-full border-white/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
aria-label={done ? 'Mark task as not done' : 'Mark task as done'}
/>
<input
value={titleField.value}
onChange={(e) => titleField.onChange(e.target.value)}
onFocus={titleField.onFocus}
onBlur={titleField.onBlur}
placeholder="Task title"
className={cn(
'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30',
done && 'text-white/50 line-through',
)}
/>
</div>
<Textarea
value={notesField.value}
onChange={(e) => notesField.onChange(e.target.value)}
onFocus={notesField.onFocus}
onBlur={notesField.onBlur}
placeholder="Add notes…"
className="min-h-16 resize-none border-none bg-transparent p-0 text-sm text-white/80 shadow-none placeholder:text-white/30 focus-visible:ring-0 dark:bg-transparent"
/>
<div className="flex flex-col gap-1.5">
{checklist.length > 0 && (
<span className="text-xs text-white/40">
{doneCount} / {checklist.length} done
</span>
)}
{checklist.map((item, index) => (
<ChecklistItemRow
// Index keys + whole-array writes are an accepted tradeoff:
// concurrent removal while someone types can shift focus.
key={index}
item={item}
onToggle={(checked) => handleToggleItem(index, checked)}
onCommitText={(text) => handleCommitItemText(index, text)}
onRemove={() => handleRemoveItem(index)}
/>
))}
<AddChecklistItemRow onAdd={handleAddItem} />
</div>
<div className="flex items-center gap-2">
<Select
value={assigned_to ?? UNASSIGNED}
onValueChange={handleAssign}
>
<SelectTrigger
size="sm"
className="w-fit gap-2 border-white/15 bg-white/5 text-white/80"
>
{assigned_to && assignee.exists && (
<HumanAvatar
size="sm"
initials={assignee.initials}
avatarObjectId={assignee.avatarObjectId}
/>
)}
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
{network?.humans?.map((human) => (
<SelectItem key={human.id} value={human.id}>
{human.email_prefix}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
function ChecklistItemRow({
item,
onToggle,
onCommitText,
onRemove,
}: {
item: ChecklistItem;
onToggle: (checked: boolean) => void;
onCommitText: (text: string) => void;
onRemove: () => void;
}) {
const textField = useLiveDraftField({
remoteValue: item.text,
commit: onCommitText,
});
return (
<div className="group/item flex items-center gap-2.5">
<Checkbox
checked={item.done}
onCheckedChange={(checked) => onToggle(checked === true)}
className="border-white/30 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
aria-label={
item.done ? 'Mark subtask as not done' : 'Mark subtask as done'
}
/>
<input
value={textField.value}
onChange={(e) => textField.onChange(e.target.value)}
onFocus={textField.onFocus}
onBlur={textField.onBlur}
placeholder="Subtask"
className={cn(
'w-full bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30',
item.done && 'text-white/40 line-through',
)}
/>
<button
type="button"
onClick={onRemove}
className="text-white/30 opacity-0 transition-opacity hover:text-white/70 group-hover/item:opacity-100"
aria-label="Remove subtask"
>
<X className="size-3.5" />
</button>
</div>
);
}
function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
const [draft, setDraft] = useState('');
const submit = () => {
const text = draft.trim();
if (!text) return;
onAdd(text);
setDraft('');
};
return (
<div className="flex items-center gap-2.5">
<Plus className="size-4 text-white/30" />
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
onBlur={submit}
placeholder="Add subtask…"
className="w-full bg-transparent text-sm text-white/70 outline-none placeholder:text-white/30"
/>
</div>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { Pencil } from 'lucide-react';
import type { Particle } from '@/api/types';
import type { ParticlePath } from '@/lib/particle-path';
@@ -8,8 +8,11 @@ import {
type LinkPreviewEntry,
} from '@/hooks/use-link-metadata';
import { extractUrls } from '@/lib/link-metadata';
import { getImmersiveTextStyle } from '@/lib/immersive-text';
import { hasMarkdownFormatting } from '@/lib/markdown';
import {
LinkPreviewCard,
LinkPreviewCardFallback,
LinkPreviewCardSkeleton,
} from '@/components/link-preview-card';
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
@@ -17,6 +20,7 @@ import { ParticleAttachments } from '@/features/particles/particle-attachments';
import { TextEditOverlay } from '@/features/particles/text-edit-overlay';
import { RelativeTimestamp } from '@/components/relative-timestamp';
import { useAuthStore } from '@/stores/auth-store';
import { useFixedDwell } from '@/hooks/use-fixed-dwell';
import { MarkdownEditor } from '@/features/compose/markdown-editor';
type TextParticle = Extract<Particle, { type: 'text' }>;
@@ -33,7 +37,6 @@ interface TextParticleViewProps {
const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
const EXTRA_S_PER_LINK = 2;
const EXTRA_S_PER_ATTACHMENT = 2;
@@ -51,18 +54,6 @@ function computeReadDuration(
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
}
function getImmersiveTextStyle(length: number) {
if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' };
if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' };
return { size: 'text-2xl', weight: 'font-normal' };
}
function hasMarkdownFormatting(content: string): boolean {
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(
content,
);
}
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
return (
<div className="flex flex-wrap gap-3">
@@ -72,7 +63,9 @@ function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
<LinkPreviewCardSkeleton />
) : entry.metadata ? (
<LinkPreviewCard metadata={entry.metadata} />
) : null}
) : (
<LinkPreviewCardFallback url={entry.url} />
)}
</div>
))}
</div>
@@ -104,29 +97,14 @@ export function TextParticleView({
urls.length,
attachments.length,
);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
useEffect(() => {
elapsedRef.current = 0;
}, [particle.id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
useFixedDwell({
id: particle.id,
durationS,
paused,
onEnded,
onProgress,
});
// Content is just bare URLs with no surrounding text
const contentTrimmed = content.trim();
@@ -209,17 +187,7 @@ export function TextParticleView({
// Mode 3: card layout
return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div
className={cn(
'flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-6 backdrop-blur-md',
'[&::-webkit-scrollbar]:w-2',
'[&::-webkit-scrollbar]:p-2',
'[&::-webkit-scrollbar-track]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-thumb]:bg-white/30',
'[&::-webkit-scrollbar-thumb]:hover:bg-white/50',
)}
>
<div className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md">
<MarkdownEditor
key={content}
value={content}
+23 -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,31 @@ 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
className="size-16"
avatarObjectId={user?.avatar_object_id}
initials={initials}
fallbackClassName="bg-primary/10 text-primary text-xl 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" />
<span className="bg-primary text-primary-foreground ring-background absolute bottom-0 right-0 flex size-5 items-center justify-center rounded-full ring-2">
<Camera className="size-2.5" />
</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;
}
@@ -0,0 +1,121 @@
import { useCallback, useMemo, useState } from 'react';
import { where } from 'firebase/firestore';
import { useLiveParticleChildren } from '@/hooks/use-particle';
import { useAuthStore } from '@/stores/auth-store';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import type { Particle } from '@/api/types';
const INITIAL_PAGE_SIZE = 9;
const PAGE_INCREMENT = 9;
// Stable references so the Firestore subscriptions don't re-attach per render.
const OPEN_STATUS_FILTER = where('status', '==', 'open');
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
const CONTAINER_TYPE_FILTER = where('type', 'in', ['stream', 'folder']);
const LEAF_TYPE_FILTER = where('type', 'in', [
'media',
'file',
'text',
'task',
'paper',
]);
interface UseContainerChildrenOptions {
/**
* When set, list only streams with this status, filtered server-side like
* the pre-folder query (reuses its composite indexes). Used by the network
* root while folders are shelved; folders keep the full mixed-type behavior
* so the container model can be picked back up later. Only streams carry
* `status`, so the filter excludes other types by itself.
*/
streamStatus?: 'open' | 'closed';
}
interface UseContainerChildrenResult {
/** All visible children, sorted by latest activity (then creation). */
items: Particle[];
isLoading: boolean;
canLoadMore: boolean;
loadMore: () => void;
}
function activityTime(particle: Particle): number {
if (
(particle.type === 'stream' || particle.type === 'folder') &&
particle.last_child_created_at
) {
return particle.last_child_created_at.getTime();
}
return particle.created_at.getTime();
}
/**
* Children of a container (network root or folder), all particle types.
*
* Two merged subscriptions because visibility scoping filters on `visible_to`
* with array-contains-any, and leaf particles don't carry that field — a
* single scoped query would silently exclude them. Containers (streams,
* folders) are visibility-scoped; leaves inherit access from their container.
*/
export function useContainerChildren(
path: ParticlePath,
{ streamStatus }: UseContainerChildrenOptions = {},
): UseContainerChildrenResult {
const { networkId } = parseParticlePath(path);
const userId = useAuthStore((s) => s.user?.id);
const visibilityScopes = useMemo(() => {
const scopes: string[] = [];
if (userId) scopes.push(`human:${userId}`);
scopes.push(`network:${networkId}`);
return scopes;
}, [userId, networkId]);
const [limit, setLimit] = useState(INITIAL_PAGE_SIZE);
const containerFilter = streamStatus
? streamStatus === 'open'
? OPEN_STATUS_FILTER
: CLOSED_STATUS_FILTER
: CONTAINER_TYPE_FILTER;
const { children: containers, isLoading: containersLoading } =
useLiveParticleChildren(path, {
orderByField: 'last_child_created_at',
orderDirection: 'desc',
visibilityScopes,
whereFilter: containerFilter,
limit,
});
// Passing undefined disables the subscription entirely in streams-only mode.
const { children: leaves, isLoading: leavesLoading } =
useLiveParticleChildren(streamStatus ? undefined : path, {
orderByField: 'created_at',
orderDirection: 'desc',
whereFilter: LEAF_TYPE_FILTER,
limit,
});
const items = useMemo(
() =>
[...containers, ...leaves].sort(
(a, b) => activityTime(b) - activityTime(a),
),
[containers, leaves],
);
// Heuristic: a query returning a full page may have more behind it.
const canLoadMore = containers.length >= limit || leaves.length >= limit;
const loadMore = useCallback(() => {
setLimit((prev) => prev + PAGE_INCREMENT);
}, []);
return {
items,
isLoading: containersLoading || leavesLoading,
canLoadMore,
loadMore,
};
}
+8 -3
View File
@@ -29,6 +29,8 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
type: T;
properties: ParticlePropertiesMap[T];
createdByHumanId: string;
// Required for container types
visibleTo?: string[];
}
export function useCreateParticle() {
@@ -59,6 +61,7 @@ export function useCreateParticle() {
params.type,
params.properties,
params.createdByHumanId,
params.visibleTo,
);
if (!CONTAINER_TYPES.has(params.type)) {
@@ -76,15 +79,17 @@ type CreateStreamParticleParams = {
properties: ParticlePropertiesMap['stream'];
createdByHumanId: string;
visibleTo?: string[];
// Container to create the stream in; defaults to the network root.
parentPath?: ParticlePath;
};
export function useCreateStreamParticle() {
return useMutation({
mutationFn: async (params: CreateStreamParticleParams) => {
const path = particlePath(params.networkId, []);
const networkCollectionPath = toFirestoreChildrenPath(path);
const path = params.parentPath ?? particlePath(params.networkId, []);
const collectionPath = toFirestoreChildrenPath(path);
return await createStreamParticle(
networkCollectionPath,
collectionPath,
params.properties,
params.createdByHumanId,
params.visibleTo,
+13 -7
View File
@@ -48,16 +48,22 @@ export function useFileInput({
useEffect(() => {
if (!enabled) return;
// Capture phase so file pastes always become attachments — ProseMirror
// would otherwise inline pasted images as ephemeral blob: URLs. Mixed
// clipboards (e.g. Excel/Word ship an image rendition alongside the text)
// must still paste as text, so only file-only pastes are intercepted.
const handlePaste = (e: ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length > 0) {
e.preventDefault();
onFilesRef.current(files);
}
const data = e.clipboardData;
if (!data) return;
const files = Array.from(data.files);
if (files.length === 0 || data.types.includes('text/plain')) return;
e.preventDefault();
e.stopPropagation();
onFilesRef.current(files);
};
window.addEventListener('paste', handlePaste);
return () => window.removeEventListener('paste', handlePaste);
window.addEventListener('paste', handlePaste, true);
return () => window.removeEventListener('paste', handlePaste, true);
}, [enabled]);
// Drag and drop handlers
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
const TICK_MS = 100;
interface UseFixedDwellOptions {
/** Reset key — restarts the timer when it changes (particle id). */
id: string;
durationS: number;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
/**
* Drives a fixed-duration dwell timer for particles without intrinsic
* playback (text, tasks, events): reports progress and fires onEnded once
* the duration elapses. Pausing freezes elapsed time rather than resetting.
*/
export function useFixedDwell({
id,
durationS,
paused,
onEnded,
onProgress,
}: UseFixedDwellOptions): void {
const elapsedRef = useRef(0);
useEffect(() => {
elapsedRef.current = 0;
}, [id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, id]);
}
@@ -1,29 +1,33 @@
import { useEffect, useState } from 'react';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
interface UseStreamKeyboardNavOptions {
streams: Array<{ id: string }>;
interface UseListKeyboardNavOptions {
items: Array<{ id: string }>;
enabled: boolean;
onNavigate: (streamId: string) => void;
onOpen: (id: string) => void;
}
export function useStreamKeyboardNav({
streams,
/**
* Shared keyboard grammar for browsable lists (network root, folders):
* / move selection, Enter opens, 19 jump, V toggles video/audio.
*/
export function useListKeyboardNav({
items,
enabled,
onNavigate,
}: UseStreamKeyboardNavOptions) {
onOpen,
}: UseListKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(
streams.length > 0 ? 0 : null,
items.length > 0 ? 0 : null,
);
const [prevStreamCount, setPrevStreamCount] = useState(streams.length);
const [prevItemCount, setPrevItemCount] = useState(items.length);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
// Select the first stream once they load and clear when empty — but not on
// Select the first item once they load and clear when empty — but not on
// every Firestore update, which would scroll the list back to the top.
if (streams.length !== prevStreamCount) {
setPrevStreamCount(streams.length);
if (streams.length === 0) {
if (items.length !== prevItemCount) {
setPrevItemCount(items.length);
if (items.length === 0) {
setSelectedIndex(null);
} else if (selectedIndex === null) {
setSelectedIndex(0);
@@ -31,7 +35,7 @@ export function useStreamKeyboardNav({
}
useEffect(() => {
if (!enabled || streams.length === 0) return;
if (!enabled || items.length === 0) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
@@ -49,19 +53,19 @@ export function useStreamKeyboardNav({
const digit = parseInt(e.key, 10);
if (digit >= 1 && digit <= 9) {
const index = digit - 1;
if (index < streams.length) {
if (index < items.length) {
e.preventDefault();
onNavigate(streams[index].id);
onOpen(items[index].id);
}
return;
}
// Enter: navigate to selected
// Enter: open selected
if (e.key === 'Enter') {
setSelectedIndex((idx) => {
if (idx !== null && idx < streams.length) {
if (idx !== null && idx < items.length) {
e.preventDefault();
onNavigate(streams[idx].id);
onOpen(items[idx].id);
}
return idx;
});
@@ -86,7 +90,7 @@ export function useStreamKeyboardNav({
setSelectedIndex((prev) => {
if (prev === null) return 0;
const next = prev + delta;
return Math.max(0, Math.min(next, streams.length - 1));
return Math.max(0, Math.min(next, items.length - 1));
});
}
};
@@ -95,7 +99,7 @@ export function useStreamKeyboardNav({
// components (ToggleGroup, etc.) consume them for their own navigation.
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [enabled, streams, onNavigate, recordingMode, setRecordingMode]);
}, [enabled, items, onOpen, recordingMode, setRecordingMode]);
return { selectedIndex };
}
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const DEFAULT_DEBOUNCE_MS = 600;
interface UseLiveDraftFieldOptions {
remoteValue: string;
commit: (value: string) => void | Promise<void>;
debounceMs?: number;
}
export interface LiveDraftField {
value: string;
onChange: (value: string) => void;
onFocus: () => void;
onBlur: () => void;
}
/**
* State for a multiplayer always-editable text field: local draft while
* typing, debounced write-through, immediate flush on blur. Incoming remote
* values are applied only while the field is unfocused, so collaborators'
* snapshot updates never clobber in-progress typing (blur flushes pending
* writes synchronously, so unfocused implies no pending draft). Concurrent
* edits to the same field are last-write-wins.
*/
export function useLiveDraftField({
remoteValue,
commit,
debounceMs = DEFAULT_DEBOUNCE_MS,
}: UseLiveDraftFieldOptions): LiveDraftField {
const [value, setValue] = useState(remoteValue);
const [focused, setFocused] = useState(false);
// Sync from remote during render (derived-state pattern) unless the user
// is editing the field.
const [prevRemote, setPrevRemote] = useState(remoteValue);
if (remoteValue !== prevRemote) {
setPrevRemote(remoteValue);
if (!focused) {
setValue(remoteValue);
}
}
// Written only in handlers; read by flush() so its identity stays stable.
const valueRef = useRef(remoteValue);
const pendingRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const commitRef = useRef(commit);
useEffect(() => {
commitRef.current = commit;
}, [commit]);
const flush = useCallback(() => {
clearTimeout(timerRef.current);
timerRef.current = undefined;
if (!pendingRef.current) return;
pendingRef.current = false;
void commitRef.current(valueRef.current);
}, []);
// Flush any pending write when the field unmounts (e.g. playback advances).
useEffect(() => flush, [flush]);
const onChange = useCallback(
(next: string) => {
valueRef.current = next;
setValue(next);
pendingRef.current = true;
clearTimeout(timerRef.current);
timerRef.current = setTimeout(flush, debounceMs);
},
[debounceMs, flush],
);
const onFocus = useCallback(() => {
setFocused(true);
}, []);
const onBlur = useCallback(() => {
setFocused(false);
flush();
}, [flush]);
return { value, onChange, onFocus, onBlur };
}
+17 -2
View File
@@ -1,4 +1,10 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import {
useCallback,
useEffect,
useRef,
useState,
type RefObject,
} from 'react';
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import {
@@ -15,6 +21,10 @@ interface UsePlaybackKeysOptions {
interface UsePlaybackKeysResult {
fastPlayback: boolean;
/** True while playback is paused by the user's space toggle/hold. */
spacePaused: boolean;
/** Clear the user's space pause (e.g. clicking the on-screen play button). */
resume: () => void;
}
/**
@@ -31,6 +41,11 @@ export function usePlaybackKeys({
useSuspendPlayback(spaceHeld, 'hold-space');
const resume = useCallback(() => {
setSpaceHeld(false);
spaceStartRef.current = 0;
}, []);
useEffect(() => {
const isExternallyPaused = () =>
selectIsPaused(usePlaybackPauseStore.getState()) && !spaceHeld;
@@ -92,5 +107,5 @@ export function usePlaybackKeys({
};
}, [mediaRef, spaceHeld]);
return { fastPlayback };
return { fastPlayback, spacePaused: spaceHeld, resume };
}
@@ -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);
@@ -11,6 +11,7 @@ interface UseStreamNavigationKeysOptions {
childrenLength: number;
mediaRef: RefObject<MediaParticleHandle | null>;
onExit: () => void;
onToggleViewMode: () => void;
}
/**
@@ -24,6 +25,7 @@ export function useStreamNavigationKeys({
childrenLength,
mediaRef,
onExit,
onToggleViewMode,
}: UseStreamNavigationKeysOptions) {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -54,10 +56,25 @@ export function useStreamNavigationKeys({
e.preventDefault();
onExit();
break;
case 'l':
case 'L':
// Registered here (not in action keys) because the toggle must work
// in list mode, where playback is suspended and action keys bail.
e.preventDefault();
onToggleViewMode();
break;
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [next, prev, currentIndex, childrenLength, mediaRef, onExit]);
}, [
next,
prev,
currentIndex,
childrenLength,
mediaRef,
onExit,
onToggleViewMode,
]);
}
@@ -1,80 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { where, type QueryFieldFilterConstraint } from 'firebase/firestore';
import { useLiveParticleChildren } from '@/hooks/use-particle';
import { useAuthStore } from '@/stores/auth-store';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import type { Particle, StreamProperties } from '@/api/types';
export type StreamParticle = Particle & {
type: 'stream';
properties: StreamProperties;
};
const INITIAL_PAGE_SIZE = 12;
const PAGE_INCREMENT = 12;
// Stable where-constraint references so the Firestore subscription only
// re-attaches when the tab actually changes, not on every render.
const OPEN_STATUS_FILTER = where('status', '==', 'open');
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
function useVisibilityScopes(userId?: string, networkId?: string) {
return useMemo(() => {
const scopes: string[] = [];
if (userId) scopes.push(`human:${userId}`);
if (networkId) scopes.push(`network:${networkId}`);
return scopes;
}, [userId, networkId]);
}
interface UseStreamParticlesOptions {
// Which streams to subscribe to
status: 'open' | 'closed';
}
interface UseStreamParticlesResult {
streams: StreamParticle[];
isLoading: boolean;
networkId: string;
/** True when more streams may exist beyond the current window. */
canLoadMore: boolean;
/** Extend the pagination window. */
loadMore: () => void;
}
export function useStreamParticles(
path: ParticlePath,
{ status }: UseStreamParticlesOptions,
): UseStreamParticlesResult {
const { networkId } = parseParticlePath(path);
const user = useAuthStore((s) => s.user);
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
const [limit, setLimit] = useState(INITIAL_PAGE_SIZE);
const whereFilter: QueryFieldFilterConstraint =
status === 'open' ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
const { children, isLoading } = useLiveParticleChildren(path, {
orderByField: 'last_child_created_at',
orderDirection: 'desc',
visibilityScopes,
whereFilter,
limit,
});
const streams = useMemo(
() => children.filter((c): c is StreamParticle => c.type === 'stream'),
[children],
);
// Heuristic: if we got back as many items as we asked for, assume there
// might be more. Clicking load-more when there are no more is a no-op.
const canLoadMore = streams.length >= limit;
const loadMore = useCallback(() => {
setLimit((prev) => prev + PAGE_INCREMENT);
}, []);
return { streams, isLoading, networkId, canLoadMore, loadMore };
}
+115 -54
View File
@@ -8,7 +8,7 @@ import {
} from 'react';
import { useAuthStore } from '@/stores/auth-store';
import type { Particle } from '@/api/types';
import { useLiveParticleChildren } from '@/hooks/use-particle';
import { useWindowedStreamParticles } from '@/hooks/use-windowed-stream-particles';
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
import { updateStreamPlaybackMarker } from '@/lib/firestore-particles';
@@ -92,56 +92,52 @@ interface UseStreamPlaybackResult {
currentIndex: number;
status: PlaybackStatus;
initialized: boolean;
/** Whether older particles exist before the loaded window (list scroll-up). */
hasMoreOlder: boolean;
/** Extend the loaded window backward. */
loadOlder: () => void;
isLoadingOlder: boolean;
next: () => void;
prev: () => void;
goTo: (index: number) => void;
goToParticle: (particleId: string) => void;
}
interface UseStreamPlaybackOptions {
/**
* When false, newly arriving particles don't pull playback forward after
* it has ended (list mode browses; selection must stay put). Default true.
*/
autoAdvanceOnNew?: boolean;
}
export function useStreamPlayback(
streamParticle: Particle & { type: 'stream' },
path: ParticlePath,
{ autoAdvanceOnNew = true }: UseStreamPlaybackOptions = {},
): UseStreamPlaybackResult {
const userId = useAuthStore((s) => s.user?.id);
const [state, dispatch] = useReducer(playbackReducer, initialState);
// Track the stream ID we've initialized for, to reset when navigating between streams
const marker = userId
? (streamParticle.playback_markers?.[userId] ?? null)
: null;
// Windowed source: only the tail (plus enough history to cover the marker)
// is loaded, instead of every particle in the stream.
const { children, hasMoreOlder, loadOlder, isLoadingOlder } =
useWindowedStreamParticles(path, { marker });
// Read via ref so the new-particle effect stays cheap to reason about.
const autoAdvanceOnNewRef = useRef(autoAdvanceOnNew);
useEffect(() => {
autoAdvanceOnNewRef.current = autoAdvanceOnNew;
}, [autoAdvanceOnNew]);
// Track the stream ID we've initialized for, to reset when navigating streams.
const initializedForRef = useRef<string | null>(null);
// Latest currentIndex for onParticleRemoved, which is passed into
// useLiveParticleChildren. Reading it through a ref keeps the callback stable
// (no re-subscription) and breaks the declaration cycle
// children -> currentIndex -> callback -> children. useEffectEvent can't be
// used here — Effect Events may not be passed to another hook.
const currentIndexRef = useRef(0);
// --- Firestore change callbacks ---
const onParticleAdded = useCallback((particle: Particle) => {
dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id });
}, []);
const onParticleRemoved = useCallback(
(removed: Particle, updatedChildren: Particle[]) => {
const fallbackIndex = Math.min(
currentIndexRef.current,
updatedChildren.length - 1,
);
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
dispatch({
type: 'PARTICLE_REMOVED',
removedParticleId: removed.id,
fallbackParticleId: fallback?.id ?? null,
});
},
[],
);
const { children } = useLiveParticleChildren(path, {
orderByField: 'created_at',
orderDirection: 'asc',
onAdded: onParticleAdded,
onRemoved: onParticleRemoved,
});
// Derive current index and particle from ID
// Derive current index and particle from ID.
const currentIndex = useMemo(() => {
if (!state.currentParticleId) return -1;
return children.findIndex((c) => c.id === state.currentParticleId);
@@ -149,12 +145,64 @@ export function useStreamPlayback(
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
// Keep the ref read by onParticleRemoved in sync with the derived index.
// Remember the last index the current particle was actually found at, so a
// removal can fall back to a sensible neighbour even though `currentIndex`
// has already gone to -1 by the time we notice.
const lastValidIndexRef = useRef(0);
useEffect(() => {
currentIndexRef.current = currentIndex;
if (currentIndex >= 0) lastValidIndexRef.current = currentIndex;
}, [currentIndex]);
// Fallback init — always sees latest children/state via useEffectEvent
// --- New tail particle → resume from end ---
// Derive arrivals from the children tail rather than Firestore change events,
// which can't distinguish a genuine new particle from pagination backfill.
const prevNewestIdRef = useRef<string | null>(null);
useEffect(() => {
if (children.length === 0) {
prevNewestIdRef.current = null;
return;
}
const newestId = children[children.length - 1].id;
const prevNewestId = prevNewestIdRef.current;
prevNewestIdRef.current = newestId;
if (prevNewestId === null || newestId === prevNewestId) return;
if (!autoAdvanceOnNewRef.current) return;
// Resume at the first particle added after where playback ended.
const prevIndex = children.findIndex((c) => c.id === prevNewestId);
const firstNew =
prevIndex >= 0
? (children[prevIndex + 1] ?? children[children.length - 1])
: children[children.length - 1];
dispatch({ type: 'PARTICLE_ADDED', particleId: firstNew.id });
}, [children]);
// --- Current particle removed (deletion) → fall back to a neighbour ---
const prevIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
const id = state.currentParticleId;
const prevIds = prevIdsRef.current;
const currIds = new Set(children.map((c) => c.id));
prevIdsRef.current = currIds;
if (!id || children.length === 0) return;
if (currIds.has(id)) return;
// Only treat as a removal if it was present before — a not-yet-arrived id
// (e.g. optimistic goToParticle) should wait, not fall back.
if (!prevIds.has(id)) return;
const fallbackIndex = Math.min(
lastValidIndexRef.current,
children.length - 1,
);
const fallback = children[Math.max(0, fallbackIndex)];
dispatch({
type: 'PARTICLE_REMOVED',
removedParticleId: id,
fallbackParticleId: fallback?.id ?? null,
});
}, [children, state.currentParticleId]);
// Fallback init — always sees latest children/state via useEffectEvent.
const initFallback = useEffectEvent(() => {
if (state.initialized || children.length === 0) return;
initializedForRef.current = streamParticle.id;
@@ -163,7 +211,7 @@ export function useStreamPlayback(
// --- Init logic: runs on every children change until initialized ---
useEffect(() => {
// Reset if we navigated to a different stream
// Reset if we navigated to a different stream.
if (
initializedForRef.current !== null &&
initializedForRef.current !== streamParticle.id
@@ -171,7 +219,7 @@ export function useStreamPlayback(
initializedForRef.current = null;
}
// Already initialized for this stream
// Already initialized for this stream.
if (state.initialized && initializedForRef.current === streamParticle.id)
return;
@@ -180,35 +228,39 @@ export function useStreamPlayback(
const playbackPosition = streamParticle.playback_markers?.[userId ?? ''];
if (!playbackPosition) {
// No marker — start from the beginning
// No marker — start from the start of the loaded window.
initializedForRef.current = streamParticle.id;
dispatch({ type: 'INIT', particleId: children[0].id });
return;
}
// Try to find the marker's target particle
// Resume at the first particle after the marker.
const found = children.find(
(c) => c.created_at.getTime() > playbackPosition.getTime(),
);
if (found) {
initializedForRef.current = streamParticle.id;
dispatch({ type: 'INIT', particleId: found.id });
return;
} else {
initializedForRef.current = streamParticle.id;
dispatch({ type: 'INIT', particleId: children[children.length - 1].id });
}
// Marker target not found yet — fall back after timeout
const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS);
return () => clearTimeout(timeout);
// Marker is older than everything loaded so far. If the window is still
// growing backward to reach it, wait for more particles to arrive.
if (hasMoreOlder) {
const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS);
return () => clearTimeout(timeout);
}
// Reached the start with no particle after the marker → caught up.
initializedForRef.current = streamParticle.id;
dispatch({ type: 'INIT', particleId: children[children.length - 1].id });
}, [
children,
streamParticle.id,
streamParticle.playback_markers,
userId,
state.initialized,
hasMoreOlder,
]);
// --- Persist playback marker (only advance forward, never backwards) ---
@@ -222,7 +274,7 @@ export function useStreamPlayback(
lastPersistedMarkerRef.current ??
streamParticle.playback_markers?.[userId];
// Only update if advancing beyond the current marker
// Only update if advancing beyond the current marker.
if (existingMarker && currentTime.getTime() <= existingMarker.getTime())
return;
@@ -251,12 +303,18 @@ export function useStreamPlayback(
}, [children, currentIndex]);
const prev = useCallback(() => {
if (currentIndex <= 0) return;
if (currentIndex < 0) return;
if (currentIndex === 0) {
// At the start of the loaded window — pull in older history so the user
// can keep going back.
if (hasMoreOlder) loadOlder();
return;
}
dispatch({
type: 'SET_PARTICLE',
particleId: children[currentIndex - 1].id,
});
}, [children, currentIndex]);
}, [children, currentIndex, hasMoreOlder, loadOlder]);
const goTo = useCallback(
(index: number) => {
@@ -280,6 +338,9 @@ export function useStreamPlayback(
currentIndex,
status: state.status,
initialized: state.initialized,
hasMoreOlder,
loadOlder,
isLoadingOlder,
next,
prev,
goTo,
@@ -0,0 +1,15 @@
import { useCallback, useState } from 'react';
export type StreamViewMode = 'player' | 'list';
export function useStreamViewMode(): {
mode: StreamViewMode;
toggle: () => void;
} {
const [mode, setMode] = useState<StreamViewMode>('player');
const toggle = useCallback(() => {
setMode((prev) => (prev === 'player' ? 'list' : 'player'));
}, []);
return { mode, toggle };
}
@@ -0,0 +1,214 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { subscribeToParticleChildren } from '@/lib/firestore-particles';
import type { Particle } from '@/api/types';
import {
toFirestoreChildrenPath,
type ParticlePath,
} from '@/lib/particle-path';
const DEFAULT_PAGE_SIZE = 30;
interface UseWindowedStreamParticlesParams {
/**
* Resume anchor (the viewer's playback marker). The window grows backward
* until it covers this timestamp so the resume particle is always loaded.
* Captured once per stream — advancing the marker during playback does not
* re-window.
*/
marker?: Date | null;
/** How many particles to add per backward growth step. */
pageSize?: number;
}
export interface UseWindowedStreamParticlesResult {
/**
* Loaded window, ascending (oldest → newest). The newest particle in the
* stream is always present — the window only ever grows backward.
*/
children: Particle[];
isLoading: boolean;
error: Error | null;
/** Whether older particles likely exist before the loaded window. */
hasMoreOlder: boolean;
/** Extend the window backward (older history). No-op when nothing remains. */
loadOlder: () => void;
isLoadingOlder: boolean;
}
/** Per-stream mutable tracking that must survive limit-driven re-subscriptions. */
interface WindowTracking {
path: string | null;
/** Newest created_at (ms) seen — tells new tail particles from backfill. */
newestMs: number | null;
/** Oldest created_at (ms) currently loaded. */
oldestMs: number | null;
/** Backward growth target (the marker, ms), frozen on first capture. */
coverageMs: number | null;
}
/**
* Live, windowed view of a stream's particles.
*
* Instead of subscribing to every child (the old behaviour), this keeps a
* `orderBy(created_at desc) limit(N)` window anchored at the newest particle
* and grows it backward on demand. Because the window is anchored at the tail
* it always contains the most recent particles, so new arrivals stream in and
* forward playback never needs a fetch. The window grows backward to:
* 1. cover the resume marker, so playback can start where the user left off;
* 2. service `loadOlder()` when the list view scrolls up.
*
* Output is reversed to ascending order to match the rest of the playback code.
*/
export function useWindowedStreamParticles(
path: ParticlePath | undefined,
{
marker = null,
pageSize = DEFAULT_PAGE_SIZE,
}: UseWindowedStreamParticlesParams = {},
): UseWindowedStreamParticlesResult {
const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [hasMoreOlder, setHasMoreOlder] = useState(false);
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
const [limit, setLimit] = useState(pageSize);
const collectionPath = path ? toFirestoreChildrenPath(path) : null;
const trackingRef = useRef<WindowTracking>({
path: null,
newestMs: null,
oldestMs: null,
coverageMs: null,
});
// Latest marker, read lazily inside the snapshot callback so a late-resolving
// marker (e.g. auth after first paint) still seeds backward coverage.
const markerRef = useRef(marker);
useEffect(() => {
markerRef.current = marker;
}, [marker]);
// Reset window state when the stream changes (render-phase adjustment — the
// blessed alternative to a reset effect, avoids cascading effect renders).
const [trackedPath, setTrackedPath] = useState(collectionPath);
if (trackedPath !== collectionPath) {
setTrackedPath(collectionPath);
setLimit(pageSize);
setChildren([]);
setIsLoading(true);
setError(null);
setHasMoreOlder(false);
setIsLoadingOlder(false);
}
useEffect(() => {
if (!collectionPath) return;
// Reset per-stream tracking on a genuine stream change, but keep it across
// limit-driven re-subscriptions (newest/coverage must persist).
const tracking = trackingRef.current;
if (tracking.path !== collectionPath) {
tracking.path = collectionPath;
tracking.newestMs = null;
tracking.oldestMs = null;
tracking.coverageMs = null;
}
const unsubscribe = subscribeToParticleChildren(collectionPath, {
orderByField: 'created_at',
orderDirection: 'desc',
limit,
onData: (descData) => {
const t = trackingRef.current;
// Lazily freeze the backward-coverage target from the marker.
if (t.coverageMs === null && markerRef.current) {
t.coverageMs = markerRef.current.getTime();
}
// Firestore caps results at `limit`; a full window means more older
// particles may exist beyond it.
const saturated = descData.length === limit;
const newest = descData[0];
const newestMs = newest ? newest.created_at.getTime() : null;
// Anti-eviction: if the window is full and genuinely newer particles
// arrived at the tail, grow the limit so the oldest loaded particles
// aren't pushed out. Skip rendering the evicted snapshot — the regrown
// query delivers the complete window a beat later.
const prevNewest = t.newestMs;
if (
saturated &&
newestMs !== null &&
prevNewest !== null &&
newestMs > prevNewest
) {
const newerCount = descData.filter(
(d) => d.created_at.getTime() > prevNewest,
).length;
if (newerCount > 0) {
t.newestMs = newestMs;
setLimit((l) => l + newerCount);
return;
}
}
if (newestMs !== null) t.newestMs = newestMs;
const ascData = descData.slice().reverse();
t.oldestMs =
ascData.length > 0 ? ascData[0].created_at.getTime() : null;
// Marker coverage: keep growing backward until the resume marker falls
// within the window (or we reach the start of the stream).
if (
saturated &&
t.coverageMs !== null &&
t.oldestMs !== null &&
t.oldestMs > t.coverageMs
) {
setLimit((l) => l + pageSize);
}
setChildren(ascData);
setHasMoreOlder(saturated);
setIsLoading(false);
setIsLoadingOlder(false);
},
onError: (err) => {
console.warn(err);
setError(err);
setIsLoading(false);
setIsLoadingOlder(false);
},
});
return () => unsubscribe();
}, [collectionPath, limit, pageSize]);
const loadOlder = useCallback(() => {
if (!hasMoreOlder || isLoadingOlder) return;
setIsLoadingOlder(true);
setLimit((l) => l + pageSize);
}, [hasMoreOlder, isLoadingOlder, pageSize]);
if (!path) {
return {
children: [],
isLoading: false,
error: null,
hasMoreOlder: false,
loadOlder: () => {},
isLoadingOlder: false,
};
}
return {
children,
isLoading,
error,
hasMoreOlder,
loadOlder,
isLoadingOlder,
};
}
+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 });
}
+6
View File
@@ -4,6 +4,12 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
/** Maximum number of file attachments per particle. */
export const MAX_ATTACHMENTS = 10;
/** Maximum duration for a media (audio/video) recording, in seconds. */
export const RECORDING_MAX_DURATION_SECONDS = 60;
/** When this many seconds or fewer remain, show the red warning state. */
export const RECORDING_WARNING_SECONDS = 10;
export const SUPPORT_EMAIL = 'team@flowylabs.ai';
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
+100 -31
View File
@@ -23,7 +23,11 @@ import {
QueryFieldFilterConstraint,
} from 'firebase/firestore';
import { firestoreDb } from '@/firebase';
import { isContainerType, ParticleSchema } from '@/api/types';
import {
isContainerType,
ParticleSchema,
UnknownParticleSchema,
} from '@/api/types';
import type {
Particle,
ParticleType,
@@ -33,6 +37,22 @@ import type {
// --- Converter ---
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Coerce the
// per-type property fields that carry timestamps.
function coerceLeafPropertyDates(
type: ParticleType,
properties: DocumentData | undefined,
): DocumentData | undefined {
if (!properties) return properties;
if (type === 'text' && properties.edited_at) {
return {
...properties,
edited_at: (properties.edited_at as Timestamp).toDate(),
};
}
return properties;
}
const particleConverter: FirestoreDataConverter<Particle> = {
toFirestore(particle: Particle): DocumentData {
const { id: _id, created_at, updated_at, ...rest } = particle;
@@ -60,6 +80,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
id: snap.id,
type: raw.type,
properties: raw.properties,
status: raw.status ?? undefined,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at
@@ -79,7 +100,6 @@ const particleConverter: FirestoreDataConverter<Particle> = {
: undefined,
huddle_active_participants:
raw.huddle_active_participants ?? undefined,
status: raw.status ?? undefined,
});
case 'folder':
return ParticleSchema.parse({
@@ -92,21 +112,16 @@ const particleConverter: FirestoreDataConverter<Particle> = {
? (raw.updated_at as Timestamp).toDate()
: undefined,
visible_to: raw.visible_to,
last_child_created_at: raw.last_child_created_at
? (raw.last_child_created_at as Timestamp).toDate()
: undefined,
});
case 'media':
case 'file':
case 'text':
case 'quest':
case 'task':
case 'paper': {
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
// particles carry `properties.edited_at`, so coerce it if present.
const properties =
type === 'text' && raw.properties?.edited_at
? {
...raw.properties,
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
}
: raw.properties;
const properties = coerceLeafPropertyDates(type, raw.properties);
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
@@ -124,11 +139,33 @@ const particleConverter: FirestoreDataConverter<Particle> = {
});
}
default:
throw new Error(`Unknown particle type: ${type}`);
// Forward compatibility: keep unrecognized types visible as
// placeholders instead of breaking the snapshot they arrive in.
return UnknownParticleSchema.parse({
id: snap.id,
type: 'unknown',
raw_type: raw.type,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at
? (raw.updated_at as Timestamp).toDate()
: undefined,
});
}
},
};
// Corrupt docs (malformed base fields, schema parse failures) shouldn't take
// down a whole subscription — skip just the bad doc and keep the rest.
function safeData(snap: QueryDocumentSnapshot<Particle>): Particle | null {
try {
return snap.data();
} catch (err) {
console.warn(`Skipping unparseable particle at ${snap.ref.path}:`, err);
return null;
}
}
// --- Typed reference helpers ---
function typedDoc(path: string) {
@@ -149,7 +186,7 @@ export function subscribeToParticle(
return onSnapshot(
typedDoc(docPath),
(snap) => {
onData(snap.exists() ? snap.data() : null);
onData(snap.exists() ? safeData(snap) : null);
},
onError,
);
@@ -161,7 +198,7 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
return null;
}
return doc.data();
return safeData(doc);
}
export interface GetParticleChildrenOptions {
@@ -184,7 +221,7 @@ export async function getParticleChildren(
orderBy(orderByField, orderDirection),
);
const snap = await getDocs(q);
return snap.docs.map((d) => d.data());
return snap.docs.flatMap((d) => safeData(d) ?? []);
}
export interface SubscribeToParticleChildrenOptions {
@@ -230,14 +267,16 @@ export function subscribeToParticleChildren(
return onSnapshot(
q,
(snap) => {
const updatedChildren = snap.docs.map((d) => d.data());
const updatedChildren = snap.docs.flatMap((d) => safeData(d) ?? []);
onData(updatedChildren);
if (onAdded || onRemoved) {
for (const change of snap.docChanges()) {
if (change.type === 'added' && onAdded) onAdded(change.doc.data());
const child = safeData(change.doc);
if (!child) continue;
if (change.type === 'added' && onAdded) onAdded(child);
if (change.type === 'removed' && onRemoved)
onRemoved(change.doc.data(), updatedChildren);
onRemoved(child, updatedChildren);
}
}
},
@@ -258,12 +297,31 @@ export function subscribeToLatestChild(
return onSnapshot(
q,
(snap) => {
onData(snap.empty ? null : snap.docs[0].data());
onData(snap.empty ? null : safeData(snap.docs[0]));
},
onError,
);
}
// Best-effort bump of the parent container's last_child_created_at when a
// child is created. The particle processor worker does this for stream
// parents, but skips folders, so the client keeps folder activity fresh
// itself. Same value semantics as the worker: the child's created_at, so it
// stays directly comparable with playback markers.
function bumpParentLastChildCreatedAt(
collectionPath: string,
childCreatedAt: Date,
): void {
const parentDocPath = collectionPath.replace(/\/children$/, '');
// The network root (networks/{id}) is not a particle doc — nothing to bump.
if (!parentDocPath.includes('/children/')) return;
updateDoc(doc(firestoreDb, parentDocPath), {
last_child_created_at: Timestamp.fromDate(childCreatedAt),
}).catch(() => {
// Non-fatal: ordering freshness only.
});
}
// This creates a new particle document with the given properties and returns its ID.
export async function createParticle<T extends ParticleType>(
collectionPath: string,
@@ -279,15 +337,21 @@ export async function createParticle<T extends ParticleType>(
);
}
const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({
id: '', // ignored by toFirestore, but needed to satisfy the type
type,
properties,
created_at: new Date(),
created_at: createdAt,
created_by_human_id: createdByHumanId,
...(visibleTo ? { visible_to: visibleTo } : {}),
// Containers start with last_child_created_at = created_at so they appear
// in activity-ordered queries before they have any children (Firestore
// orderBy drops docs missing the field).
...(isContainerType(type) ? { last_child_created_at: createdAt } : {}),
});
const ref = await addDoc(typedCollection(collectionPath), particle);
bumpParentLastChildCreatedAt(collectionPath, createdAt);
return ref.id;
}
@@ -301,19 +365,32 @@ export async function createStreamParticle(
throw new Error('visibleTo is required for streams and cannot be empty');
}
const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({
id: '',
type: 'stream',
properties,
created_at: new Date(),
status: 'open',
created_at: createdAt,
created_by_human_id: createdByHumanId,
visible_to: visibleTo,
status: 'open',
last_child_created_at: createdAt,
});
const ref = await addDoc(typedCollection(collectionPath), particle);
bumpParentLastChildCreatedAt(collectionPath, createdAt);
return ref.id;
}
export async function updateStreamStatus(
docPath: string,
status: 'open' | 'closed',
): Promise<void> {
await updateDoc(typedDoc(docPath), {
status,
updated_at: serverTimestamp(),
});
}
// This allows updating properties without overwriting the entire properties object
export async function updateParticleProperties<T extends ParticleType>(
docPath: string,
@@ -382,14 +459,6 @@ export async function updateParticle(
});
}
export async function updateStreamStatus(
docPath: string,
status: 'open' | 'closed',
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
}
/**
* Soft-delete (tombstone) a non-container particle. The Firestore doc stays
* in place so concurrent viewers see the deletion inline rather than being
+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,
};
}
+7
View File
@@ -0,0 +1,7 @@
/** Font scale for short messages shown as large centered text. Shared by the
* composer and the posted view so editing matches the final render. */
export function getImmersiveTextStyle(length: number) {
if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' };
if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' };
return { size: 'text-2xl', weight: 'font-normal' };
}
+8
View File
@@ -14,3 +14,11 @@ export function extractUrls(text: string): string[] {
// should only yield a single preview card.
return Array.from(new Set(Array.from(text.matchAll(URL_REGEX), (m) => m[0])));
}
export function domainFromUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
+5
View File
@@ -0,0 +1,5 @@
export function hasMarkdownFormatting(content: string): boolean {
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*\S[^*]*\*|\b_\S[^_]*_\b|^>/m.test(
content,
);
}
+91
View File
@@ -0,0 +1,91 @@
import {
CircleCheck,
FileText,
Folder,
HelpCircle,
Image,
MessageSquare,
Mic,
Radio,
StickyNote,
Trash2,
Video,
type LucideIcon,
} from 'lucide-react';
import { isParticleDeleted, type Particle } from '@/api/types';
export function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) {
case 'stream':
return Radio;
case 'folder':
return Folder;
case 'text':
return MessageSquare;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('video/')) return Video;
if (mime.startsWith('audio/')) return Mic;
if (mime.startsWith('image/')) return Image;
return Video;
}
case 'file':
return FileText;
case 'task':
return CircleCheck;
case 'paper':
return StickyNote;
case 'unknown':
return HelpCircle;
}
}
export function getMessagePreview(particle: Particle): string {
if (isParticleDeleted(particle)) return 'Deleted particle';
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'text':
return particle.properties.content;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('image/')) return 'Photo';
if (mime.startsWith('video/') || mime.startsWith('audio/')) {
const transcriptText = particle.properties.transcript?.transcript;
if (transcriptText) return transcriptText;
return mime.startsWith('video/') ? 'Video clip' : 'Voice note';
}
return 'Media';
}
case 'file':
return particle.properties.filename;
case 'task':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'unknown':
return 'Unsupported particle';
}
}
export function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'task':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'text':
return particle.properties.content.slice(0, 30);
case 'media':
return particle.type;
case 'unknown':
return 'Unsupported particle';
}
}
+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 {
@@ -10,6 +10,7 @@ import { create } from 'zustand';
* |--------|-----------------|--------------------------------------------|
* | record | idle | start a media (camera/mic) recording |
* | text | idle | open the text compose step |
* | task | idle | open the task compose step |
* | stop | recording | finish recording → review |
* | cancel | recording, etc. | abort recording / discard review |
* | send | reviewing | submit the recorded particle |
@@ -18,7 +19,13 @@ import { create } from 'zustand';
* ultimately invoke the same callbacks the intent dispatcher does, so the
* guard logic stays in one place.
*/
export type ComposeIntent = 'record' | 'text' | 'stop' | 'cancel' | 'send';
export type ComposeIntent =
| 'record'
| 'text'
| 'task'
| 'stop'
| 'cancel'
| 'send';
interface ComposeIntentState {
intent: { kind: ComposeIntent } | null;
+64
View File
@@ -151,6 +151,34 @@
background: oklch(1 0 0 / 35%);
}
/* Message cards: more prominent scrollbar than the subtle global default.
The descendant form reaches scroll containers we don't own (Crepe's .milkdown). */
.scrollbar-card::-webkit-scrollbar,
.scrollbar-card ::-webkit-scrollbar {
width: 8px;
}
.scrollbar-card::-webkit-scrollbar-thumb,
.scrollbar-card ::-webkit-scrollbar-thumb {
background: oklch(1 0 0 / 30%);
border-radius: 9999px;
}
.scrollbar-card::-webkit-scrollbar-thumb:hover,
.scrollbar-card ::-webkit-scrollbar-thumb:hover {
background: oklch(1 0 0 / 50%);
}
/* Message layout: the compose editor and the posted card derive their widths
from one shared content column, so line wrapping while editing matches the
final render. The editor gutter additionally fits Crepe's block drag handle
(66px wide, offset 16px left of the block). */
:root {
--message-content-width: 39rem;
--message-card-padding: 1.5rem;
--message-editor-gutter: 5.5rem;
}
/* Frameless window drag regions */
.drag-region {
-webkit-app-region: drag;
@@ -158,3 +186,39 @@
.no-drag {
-webkit-app-region: no-drag;
}
/* Recording time-limit progress bar: a single CSS animation fills the bar
left→right over the recording duration (duration set inline), avoiding
per-frame React renders. */
@keyframes record-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.record-progress {
transform: scaleX(0);
animation-name: record-progress;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
/* Pulsing red glow around the recording overlay during the final warning window. */
@keyframes record-warning-glow {
0%,
100% {
box-shadow:
inset 0 0 0 2px var(--destructive),
inset 0 0 24px 0 oklch(0.6 0.24 27 / 0.25);
}
50% {
box-shadow:
inset 0 0 0 3px var(--destructive),
inset 0 0 60px 0 oklch(0.6 0.24 27 / 0.5);
}
}
.record-warning-glow {
animation: record-warning-glow 1s ease-in-out infinite;
}
+26 -7
View File
@@ -16,6 +16,7 @@ import {
arrayRemove,
FieldPath,
type DocumentData,
type DocumentSnapshot,
type FirestoreDataConverter,
type QueryDocumentSnapshot,
type SnapshotOptions,
@@ -129,6 +130,18 @@ const particleConverter: FirestoreDataConverter<Particle> = {
},
};
// Parse a snapshot defensively: newer clients may write particle types this
// app version doesn't know yet. A single unparseable doc must not break the
// whole subscription, so skip it instead of throwing inside onSnapshot.
function safeData(snap: DocumentSnapshot<Particle>): Particle | null {
try {
return snap.data() ?? null;
} catch (error) {
console.warn(`Skipping unparseable particle ${snap.ref.path}:`, error);
return null;
}
}
// --- Typed reference helpers ---
function typedDoc(path: string) {
@@ -149,7 +162,7 @@ export function subscribeToParticle(
return onSnapshot(
typedDoc(docPath),
(snap) => {
onData(snap.exists() ? snap.data() : null);
onData(safeData(snap));
},
onError,
);
@@ -160,7 +173,7 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
if (!docSnap.exists()) {
return null;
}
return docSnap.data();
return safeData(docSnap);
}
export interface GetParticleChildrenOptions {
@@ -183,7 +196,9 @@ export async function getParticleChildren(
orderBy(orderByField, orderDirection),
);
const snap = await getDocs(q);
return snap.docs.map((d) => d.data());
return snap.docs
.map((d) => safeData(d))
.filter((p): p is Particle => p !== null);
}
export interface SubscribeToParticleChildrenOptions {
@@ -229,14 +244,18 @@ export function subscribeToParticleChildren(
return onSnapshot(
q,
(snap) => {
const updatedChildren = snap.docs.map((d) => d.data());
const updatedChildren = snap.docs
.map((d) => safeData(d))
.filter((p): p is Particle => p !== null);
onData(updatedChildren);
if (onAdded || onRemoved) {
for (const change of snap.docChanges()) {
if (change.type === 'added' && onAdded) onAdded(change.doc.data());
const changed = safeData(change.doc);
if (!changed) continue;
if (change.type === 'added' && onAdded) onAdded(changed);
if (change.type === 'removed' && onRemoved)
onRemoved(change.doc.data(), updatedChildren);
onRemoved(changed, updatedChildren);
}
}
},
@@ -257,7 +276,7 @@ export function subscribeToLatestChild(
return onSnapshot(
q,
(snap) => {
onData(snap.empty ? null : snap.docs[0].data());
onData(snap.empty ? null : safeData(snap.docs[0]));
},
onError,
);