Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11d88dbb1b |
@@ -79,7 +79,7 @@ jobs:
|
||||
run: |
|
||||
if [ "${{ inputs.module }}" = "migrations" ]; then
|
||||
kubectl delete job migrations --ignore-not-found
|
||||
skaffold run -p migrations
|
||||
skaffold run -p migrations --tail
|
||||
else
|
||||
skaffold run -p ${{ inputs.environment }} -m ${{ inputs.module }}
|
||||
fi
|
||||
|
||||
@@ -5,4 +5,3 @@ build/
|
||||
compile_commands.json
|
||||
CMakeLists.txt.user
|
||||
tags
|
||||
project.el
|
||||
|
||||
+2
-2
@@ -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
|
||||
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev --tail
|
||||
|
||||
.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
|
||||
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod --tail
|
||||
|
||||
# ---- Deploy ----
|
||||
# Use MODULE=orion or MODULE=particleprocessor or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.:
|
||||
|
||||
@@ -147,11 +147,6 @@ 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))
|
||||
@@ -179,7 +174,7 @@ func main() {
|
||||
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
|
||||
|
||||
// Particles
|
||||
mux.Handle("GET /particles/{id}/download", withAuth(h.GetObjectDownloadUrl))
|
||||
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
|
||||
|
||||
// Link metadata
|
||||
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
||||
|
||||
@@ -73,7 +73,6 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -335,83 +334,6 @@ 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
|
||||
// ============================================================================
|
||||
@@ -795,8 +717,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetObjectDownloadUrl returns a fresh signed URL for media/file particles.
|
||||
func (h *Handler) GetObjectDownloadUrl(w http.ResponseWriter, r *http.Request) {
|
||||
// DownloadParticleMedia returns a fresh signed URL for media/file particles.
|
||||
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := middleware.EmailFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
@@ -1085,7 +1007,6 @@ func humanToDTO(h *human.Human) Human {
|
||||
Email: h.Email,
|
||||
EmailPrefix: h.EmailPrefix,
|
||||
EmailNotificationsEnabled: h.EmailNotificationsEnabled,
|
||||
AvatarObjectID: h.AvatarObjectID,
|
||||
CreatedAt: h.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ type Human struct {
|
||||
Email string
|
||||
EmailPrefix string
|
||||
EmailNotificationsEnabled bool
|
||||
AvatarObjectID *string
|
||||
LastEmailNotificationSentAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ 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 {
|
||||
@@ -51,9 +50,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, avatar_object_id FROM humans WHERE email = $1`,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`,
|
||||
email,
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
@@ -67,9 +66,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
|
||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
||||
var h Human
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE id = $1`,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
@@ -114,7 +113,7 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
|
||||
|
||||
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans`,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -124,7 +123,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, &h.AvatarObjectID); err != nil {
|
||||
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.EmailPrefix = emailPrefix(h.Email)
|
||||
@@ -160,17 +159,3 @@ func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) updateAvatarObjectID(ctx context.Context, id string, objectID *string) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE humans SET avatar_object_id = $2 WHERE id = $1`,
|
||||
id, objectID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ import (
|
||||
|
||||
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("human not found")
|
||||
ErrInvalidParam = errors.New("invalid param")
|
||||
)
|
||||
var ErrNotFound = errors.New("human not found")
|
||||
|
||||
type Service interface {
|
||||
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
||||
@@ -25,8 +22,6 @@ 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 {
|
||||
@@ -93,22 +88,3 @@ func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) UpdateAvatar(ctx context.Context, id string, objectID string) error {
|
||||
if objectID == "" {
|
||||
return ErrInvalidParam
|
||||
}
|
||||
err := s.repo.updateAvatarObjectID(ctx, id, utils.CreateOptionalString(objectID))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) DeleteAvatar(ctx context.Context, id string) error {
|
||||
err := s.repo.updateAvatarObjectID(ctx, id, nil)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -56,19 +56,4 @@ 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)
|
||||
}
|
||||
|
||||
@@ -69,9 +69,9 @@ spec:
|
||||
name: shared-secrets
|
||||
key: STRIPE_WEBHOOK_SECRET
|
||||
- name: "STRIPE_PRICE_PRO_MONTHLY"
|
||||
value: "price_1ToweOJu6RWBXAm2w2wTo8VL"
|
||||
value: "price_1TMBElJu6RWBXAm2pPthUoh6"
|
||||
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
||||
value: "price_1TowexJu6RWBXAm2G9M0WJBK"
|
||||
value: "price_1TMBElJu6RWBXAm2TOpzdXk5"
|
||||
- name: "BILLING_SUCCESS_URL"
|
||||
value: "llink://billing/success"
|
||||
- name: "BILLING_CANCEL_URL"
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE humans
|
||||
DROP COLUMN IF EXISTS avatar_object_id;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,6 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE humans
|
||||
ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL;
|
||||
|
||||
COMMIT;
|
||||
+1
-17
@@ -1,24 +1,8 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>llink</title>
|
||||
<script>
|
||||
// Apply the stored theme before first paint to avoid a flash. Mirrors
|
||||
// the logic in stores/theme-store.ts; defaults to dark.
|
||||
try {
|
||||
var m = localStorage.getItem('llink:theme');
|
||||
var dark =
|
||||
m === 'light'
|
||||
? false
|
||||
: m === 'system'
|
||||
? matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: true;
|
||||
if (dark) document.documentElement.classList.add('dark');
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Flowy.llink",
|
||||
"productName": "Flowy.llink",
|
||||
"version": "1.9.0",
|
||||
"version": "1.5.2",
|
||||
"description": "Flowy.llink is a video messaging app for teams",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
@@ -64,7 +64,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -23,18 +23,12 @@ import {
|
||||
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { InAppAutoplayCard } from '@/components/in-app-autoplay-card';
|
||||
import { useOnboardingStore } from '@/stores/onboarding-store';
|
||||
import { OnboardingOverlay } from '@/features/onboarding/onboarding-overlay';
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
const App = () => {
|
||||
const status = useAuthStore((s) => s.status);
|
||||
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||
const hasCompletedOnboarding = useOnboardingStore(
|
||||
(s) => s.hasCompletedOnboarding,
|
||||
);
|
||||
const markOnboardingComplete = useOnboardingStore((s) => s.markComplete);
|
||||
|
||||
useEffect(() => {
|
||||
restoreSession();
|
||||
@@ -52,10 +46,6 @@ const App = () => {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
return <OnboardingOverlay onComplete={markOnboardingComplete} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PusherProvider>
|
||||
<AuthenticatedApp />
|
||||
|
||||
@@ -42,12 +42,16 @@ class ApiClient {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async send(
|
||||
private async fetch(
|
||||
method: string,
|
||||
path: string,
|
||||
init: { headers?: Record<string, string>; body?: BodyInit } = {},
|
||||
body?: unknown,
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = { ...init.headers };
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const token = this.config.getToken();
|
||||
if (token) {
|
||||
@@ -57,7 +61,7 @@ class ApiClient {
|
||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: init.body,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
@@ -73,17 +77,6 @@ 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,
|
||||
@@ -144,25 +137,6 @@ 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) {
|
||||
|
||||
+12
-42
@@ -6,7 +6,6 @@ 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>;
|
||||
@@ -146,21 +145,14 @@ export const TextPropertiesSchema = z.object({
|
||||
});
|
||||
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
||||
|
||||
export const ChecklistItemSchema = z.object({
|
||||
text: z.string(),
|
||||
done: z.boolean(),
|
||||
});
|
||||
export type ChecklistItem = z.infer<typeof ChecklistItemSchema>;
|
||||
|
||||
export const TaskPropertiesSchema = z.object({
|
||||
export const QuestPropertiesSchema = z.object({
|
||||
title: z.string(),
|
||||
notes: z.string().optional(),
|
||||
checklist: z.array(ChecklistItemSchema).optional(),
|
||||
description: z.string(),
|
||||
status: z.string().optional(),
|
||||
// humanId
|
||||
assigned_to: z.string().optional(),
|
||||
done: z.boolean(),
|
||||
});
|
||||
export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
|
||||
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
|
||||
|
||||
export const PaperPropertiesSchema = z.object({
|
||||
title: z.string(),
|
||||
@@ -201,7 +193,7 @@ export interface ParticlePropertiesMap {
|
||||
media: MediaProperties;
|
||||
file: FileProperties;
|
||||
text: TextProperties;
|
||||
task: TaskProperties;
|
||||
quest: QuestProperties;
|
||||
paper: PaperProperties;
|
||||
}
|
||||
|
||||
@@ -218,9 +210,6 @@ 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()),
|
||||
@@ -231,6 +220,7 @@ 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'),
|
||||
@@ -238,9 +228,6 @@ 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'),
|
||||
@@ -260,9 +247,8 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('task'),
|
||||
properties: TaskPropertiesSchema,
|
||||
reactions: ReactionsSchema,
|
||||
type: z.literal('quest'),
|
||||
properties: QuestPropertiesSchema,
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
@@ -272,28 +258,17 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
||||
}),
|
||||
]);
|
||||
|
||||
// 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 Particle = z.infer<typeof ParticleSchema>;
|
||||
|
||||
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'];
|
||||
export type ParticleType = Particle['type'];
|
||||
|
||||
/** Container types can have children subcollections */
|
||||
export const CONTAINER_TYPES: ReadonlySet<Particle['type']> = new Set([
|
||||
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set([
|
||||
'stream',
|
||||
'folder',
|
||||
]);
|
||||
|
||||
export function isContainerType(type: Particle['type']): boolean {
|
||||
export function isContainerType(type: ParticleType): boolean {
|
||||
return CONTAINER_TYPES.has(type);
|
||||
}
|
||||
|
||||
@@ -302,11 +277,6 @@ 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({
|
||||
|
||||
@@ -29,14 +29,14 @@ export function ComposingIndicator({
|
||||
return (
|
||||
<div
|
||||
key={u.humanId}
|
||||
className="bg-card/70 border-border flex rotate-180 items-center gap-1.5 rounded-full border px-2 py-1 backdrop-blur-sm"
|
||||
className="flex rotate-180 items-center gap-1.5 rounded-full bg-white/10 px-2 py-1 backdrop-blur-sm"
|
||||
>
|
||||
<span className="flex gap-0.5">
|
||||
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:0ms]" />
|
||||
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:150ms]" />
|
||||
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:300ms]" />
|
||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:0ms]" />
|
||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:150ms]" />
|
||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" />
|
||||
</span>
|
||||
<span className="text-muted-foreground whitespace-nowrap text-[10px]">
|
||||
<span className="whitespace-nowrap text-[10px] text-white/50">
|
||||
{displayName} {modeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -38,23 +38,23 @@ export function ConfirmDestructiveOverlay({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<div
|
||||
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
|
||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||
<KeyHint
|
||||
keys="Esc"
|
||||
onClick={onClose}
|
||||
title="Close (or press Esc)"
|
||||
className="text-muted-foreground text-xs"
|
||||
className="text-xs text-white/30"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">{description}</div>
|
||||
<div className="text-sm text-white/60">{description}</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||
|
||||
interface HumanAvatarProps extends Omit<
|
||||
React.ComponentProps<typeof Avatar>,
|
||||
'children'
|
||||
> {
|
||||
/** Object id of the human's profile picture, if any. */
|
||||
avatarObjectId?: string | null;
|
||||
/** Initials rendered while loading or when no picture is set. */
|
||||
initials: string;
|
||||
/** Extra classes for the initials fallback. */
|
||||
fallbackClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a human's avatar: their profile picture when set (resolved to a
|
||||
* signed URL), otherwise their initials. The fallback also shows while the
|
||||
* image loads or if it fails, so this is a drop-in for the initials-only
|
||||
* <Avatar> usages throughout the app.
|
||||
*/
|
||||
export function HumanAvatar({
|
||||
avatarObjectId,
|
||||
initials,
|
||||
fallbackClassName,
|
||||
...props
|
||||
}: HumanAvatarProps) {
|
||||
const url = useAvatarUrl(avatarObjectId);
|
||||
return (
|
||||
<Avatar {...props}>
|
||||
<AvatarImage src={url} alt={initials} />
|
||||
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import { Fragment } from 'react';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Chip restyle of the design-system Kbd, kept in one place. Tints to the
|
||||
surrounding text color so it reads on themed panels and over media alike. */
|
||||
/** Dark-overlay chip restyle of the design-system Kbd, kept in one place. */
|
||||
const chipClass =
|
||||
'rounded bg-current/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
|
||||
'rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
|
||||
|
||||
interface KeyHintProps {
|
||||
/** Key chip(s): "Esc" or ["Esc", "Q"]. */
|
||||
@@ -69,7 +68,7 @@ export function KeyHint({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title={title}
|
||||
className={cn(
|
||||
'cursor-pointer rounded transition-opacity hover:opacity-80',
|
||||
'cursor-pointer rounded transition-colors hover:text-white/80',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -48,19 +48,19 @@ export function KeybindingsOverlay({
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
|
||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||
<KeyHint
|
||||
keys={['Esc', '?']}
|
||||
separator="or"
|
||||
onClick={onClose}
|
||||
title="Close (or press Esc / ?)"
|
||||
className="text-muted-foreground text-xs"
|
||||
className="text-xs text-white/30"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
@@ -68,19 +68,21 @@ export function KeybindingsOverlay({
|
||||
<div className="flex flex-col gap-5">
|
||||
{groups.map((group) => (
|
||||
<section key={group.label}>
|
||||
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{group.bindings.map((binding) => (
|
||||
<li
|
||||
key={binding.description}
|
||||
className="border-border flex items-center justify-between border-b pb-1 last:border-0 last:pb-0"
|
||||
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0"
|
||||
>
|
||||
<span className="text-sm">{binding.description}</span>
|
||||
<span className="text-sm text-white/70">
|
||||
{binding.description}
|
||||
</span>
|
||||
<KeyHint
|
||||
keys={binding.keys}
|
||||
className="text-muted-foreground flex items-center gap-1"
|
||||
className="flex items-center gap-1 text-white/60"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Copy, ExternalLink, Globe } from 'lucide-react';
|
||||
import { domainFromUrl, type LinkMetadata } from '@/lib/link-metadata';
|
||||
import type { LinkMetadata } from '@/lib/link-metadata';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { platform } from '@/lib/platform';
|
||||
@@ -22,7 +22,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card text-card-foreground border-border max-w-sm overflow-hidden rounded-2xl border"
|
||||
className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpen(e);
|
||||
@@ -39,7 +39,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 p-3">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-xs text-white/50">
|
||||
{metadata.favicon ? (
|
||||
<img
|
||||
src={metadata.favicon}
|
||||
@@ -57,12 +57,12 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
<span className="truncate">{metadata.domain}</span>
|
||||
</div>
|
||||
{metadata.title && (
|
||||
<p className="truncate text-sm font-semibold leading-snug">
|
||||
<p className="truncate text-sm font-semibold leading-snug text-white">
|
||||
{metadata.title}
|
||||
</p>
|
||||
)}
|
||||
{metadata.description && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-white/70">
|
||||
{metadata.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -71,7 +71,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
@@ -80,7 +80,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
@@ -93,30 +93,14 @@ 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="bg-card border-border max-w-sm overflow-hidden rounded-2xl border">
|
||||
<Skeleton className="h-32 w-full rounded-none" />
|
||||
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
|
||||
<Skeleton className="h-32 w-full rounded-none bg-white/5" />
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
||||
<Skeleton className="h-4 w-48 bg-white/10" />
|
||||
<Skeleton className="h-3 w-full bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,13 +34,13 @@ export function ScreenSourcePicker({
|
||||
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
||||
|
||||
return (
|
||||
<div className="bg-scrim/80 absolute inset-0 z-50 flex items-center justify-center">
|
||||
<div className="bg-popover text-popover-foreground mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg shadow-xl">
|
||||
<div className="border-border flex items-center justify-between border-b px-5 py-4">
|
||||
<h2 className="text-base font-medium">{title}</h2>
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
||||
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||
<h2 className="text-base font-medium text-zinc-100">{title}</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
className="text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -48,7 +48,7 @@ export function ScreenSourcePicker({
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
<p className="text-center text-sm text-zinc-400">
|
||||
Loading sources...
|
||||
</p>
|
||||
) : (
|
||||
@@ -73,17 +73,17 @@ export function ScreenSourcePicker({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-border flex justify-end gap-2 border-t px-5 py-3">
|
||||
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="hover:bg-accent text-muted-foreground rounded-md px-4 py-2 text-sm"
|
||||
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={!selectedId}
|
||||
onClick={() => selectedId && onSelect(selectedId)}
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-40"
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-40 disabled:hover:bg-blue-600"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
@@ -106,7 +106,7 @@ function SourceSection({
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
@@ -116,8 +116,8 @@ function SourceSection({
|
||||
onClick={() => onSelect(source.id)}
|
||||
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
||||
selectedId === source.id
|
||||
? 'border-primary bg-accent'
|
||||
: 'border-transparent bg-muted hover:border-border'
|
||||
? 'border-blue-500 bg-zinc-800'
|
||||
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
@@ -125,7 +125,7 @@ function SourceSection({
|
||||
alt={source.name}
|
||||
className="aspect-video w-full object-cover"
|
||||
/>
|
||||
<p className="text-muted-foreground truncate px-2 py-1.5 text-xs">
|
||||
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">
|
||||
{source.name}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -39,7 +39,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-scrim/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,7 +16,7 @@ function ScrollArea({
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
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 [&>div]:!w-full"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
@@ -47,7 +47,7 @@ function Slider({
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-ring bg-background ring-ring/50 relative block size-3 shrink-0 select-none rounded-full border transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:outline-hidden focus-visible:ring-3 active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
|
||||
@@ -6,13 +6,11 @@ import {
|
||||
OctagonXIcon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const isDark = useThemeStore((s) => s.isDark);
|
||||
return (
|
||||
<Sonner
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
theme="dark"
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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 };
|
||||
@@ -21,7 +21,7 @@ export function WindowControls() {
|
||||
size="icon-sm"
|
||||
onClick={() => platform.window.minimize()}
|
||||
aria-label="Minimize"
|
||||
className="text-muted-foreground rounded"
|
||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
@@ -34,7 +34,7 @@ export function WindowControls() {
|
||||
size="icon-sm"
|
||||
onClick={() => platform.window.maximize()}
|
||||
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
||||
className="text-muted-foreground rounded"
|
||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
||||
>
|
||||
{isMaximized ? <Copy /> : <Square />}
|
||||
</Button>
|
||||
@@ -47,7 +47,7 @@ export function WindowControls() {
|
||||
size="icon-sm"
|
||||
onClick={() => platform.window.close()}
|
||||
aria-label="Close"
|
||||
className="hover:text-destructive text-muted-foreground rounded"
|
||||
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
@@ -172,7 +172,7 @@ export function AttachmentLightbox({
|
||||
}}
|
||||
>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-scrim/90 fixed inset-0 z-50 backdrop-blur-sm duration-100" />
|
||||
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/90 backdrop-blur-sm duration-100" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 flex items-center justify-center p-16 outline-none duration-100"
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,
|
||||
@@ -62,7 +61,7 @@ function AttachmentThumbnail({
|
||||
tabIndex={previewable ? 0 : undefined}
|
||||
onClick={previewable && onPreview ? onPreview : undefined}
|
||||
className={cn(
|
||||
'bg-muted group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg',
|
||||
'group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10',
|
||||
previewable && 'cursor-pointer',
|
||||
isError && 'ring-1 ring-red-400/50',
|
||||
)}
|
||||
@@ -75,19 +74,19 @@ function AttachmentThumbnail({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-0.5 px-1">
|
||||
<FileIcon className="text-muted-foreground size-5" />
|
||||
<span className="text-muted-foreground max-w-full truncate text-[9px]">
|
||||
<FileIcon className="size-5 text-white/60" />
|
||||
<span className="max-w-full truncate text-[9px] text-white/50">
|
||||
{attachment.file.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[9px]">
|
||||
<span className="text-[9px] text-white/40">
|
||||
{formatFileSize(attachment.file.size)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="bg-scrim/50 absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="size-4 animate-spin text-white" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<Loader2 className="size-4 animate-spin text-white/70" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -97,7 +96,7 @@ function AttachmentThumbnail({
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="bg-scrim/70 absolute right-0.5 top-0.5 hidden rounded-full p-0.5 text-white/80 hover:text-white group-hover:block"
|
||||
className="absolute right-0.5 top-0.5 hidden rounded-full bg-black/70 p-0.5 text-white/70 hover:text-white group-hover:block"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
@@ -108,26 +107,25 @@ function AttachmentThumbnail({
|
||||
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||
if (entry.isLoading) {
|
||||
return (
|
||||
<div className="bg-muted flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg p-2">
|
||||
<Skeleton className="h-2 w-16" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
|
||||
<Skeleton className="h-2 w-16 bg-white/10" />
|
||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Metadata fetch can fail; fall back to the bare URL so the link stays usable.
|
||||
if (!entry.metadata) return null;
|
||||
|
||||
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 ?? entry.url)}
|
||||
className="bg-muted hover:bg-accent flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg px-2 py-1.5 text-left transition-colors"
|
||||
onClick={() => platform.link.openExternal(metadata.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="text-muted-foreground flex items-center gap-1 text-[10px]">
|
||||
{metadata?.favicon ? (
|
||||
<div className="flex items-center gap-1 text-[10px] text-white/40">
|
||||
{metadata.favicon ? (
|
||||
<img
|
||||
src={metadata.favicon}
|
||||
alt=""
|
||||
@@ -139,11 +137,11 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||
) : (
|
||||
<Globe className="size-3" />
|
||||
)}
|
||||
<span className="truncate">{domain}</span>
|
||||
<span className="truncate">{metadata.domain}</span>
|
||||
</div>
|
||||
{title && (
|
||||
<p className="text-foreground line-clamp-2 text-[11px] font-medium leading-tight">
|
||||
{title}
|
||||
{metadata.title && (
|
||||
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
|
||||
{metadata.title}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
@@ -197,7 +195,7 @@ export function AttachmentStrip({
|
||||
e.stopPropagation();
|
||||
onAddClick();
|
||||
}}
|
||||
className="border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed transition-colors"
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
|
||||
@@ -19,9 +19,7 @@ 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 { TaskComposeStep } from '@/features/compose/task-compose-step';
|
||||
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
|
||||
import type { TaskProperties } from '@/api/types';
|
||||
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
||||
@@ -29,11 +27,7 @@ import { useMediaDevices } from '@/hooks/use-media-devices';
|
||||
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
|
||||
import { useFileInput } from '@/hooks/use-file-input';
|
||||
import { createImageThumbnail } from '@/lib/image-thumbnail';
|
||||
import {
|
||||
HOLD_THRESHOLD_MS,
|
||||
MAX_ATTACHMENT_SIZE_BYTES,
|
||||
MAX_ATTACHMENTS,
|
||||
} from '@/lib/constants';
|
||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from '@/lib/constants';
|
||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
@@ -46,14 +40,11 @@ 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.
|
||||
@@ -61,8 +52,12 @@ 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;
|
||||
|
||||
/**
|
||||
* Self-contained compose overlay. Each consumer renders its own instance
|
||||
* with props that determine the mode (new stream vs. reply).
|
||||
@@ -73,6 +68,7 @@ export function ComposeOverlay({
|
||||
onActiveChange,
|
||||
onStepChange,
|
||||
onParticleCreated,
|
||||
disabled,
|
||||
}: ComposeOverlayProps) {
|
||||
const [step, setStep] = useState<ComposeStep>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -102,14 +98,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;
|
||||
}, [quotaExhausted, recordingSource]);
|
||||
}, [disabled, quotaExhausted, recordingSource]);
|
||||
|
||||
const setStepSync = useCallback((next: ComposeStep) => {
|
||||
stepRef.current = next;
|
||||
@@ -146,7 +142,6 @@ export function ComposeOverlay({
|
||||
setReviewDurationMs(0);
|
||||
setReviewMimeType(null);
|
||||
setRecordingSource('media');
|
||||
pendingArtifactRef.current = null;
|
||||
setAttachments((prev) => {
|
||||
revokeAttachmentThumbnails(prev);
|
||||
return [];
|
||||
@@ -335,15 +330,7 @@ export function ComposeOverlay({
|
||||
if (!userId) return;
|
||||
|
||||
let particleId: undefined | string;
|
||||
const pendingArtifact = pendingArtifactRef.current;
|
||||
if (pendingArtifact) {
|
||||
particleId = await createParticle.mutateAsync({
|
||||
path,
|
||||
type: pendingArtifact.type,
|
||||
properties: pendingArtifact.properties,
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
} else if (textContent.trim()) {
|
||||
if (textContent.trim()) {
|
||||
particleId = await createParticle.mutateAsync({
|
||||
path,
|
||||
type: 'text',
|
||||
@@ -467,10 +454,14 @@ 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 (quota) and screen-vs-media branching live in one place.
|
||||
// guards (disabled, 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.',
|
||||
@@ -493,26 +484,6 @@ 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') {
|
||||
@@ -566,9 +537,6 @@ export function ComposeOverlay({
|
||||
case 'text':
|
||||
handleTextIntent();
|
||||
break;
|
||||
case 'task':
|
||||
handleTaskIntent();
|
||||
break;
|
||||
case 'stop':
|
||||
handleStopIntent();
|
||||
break;
|
||||
@@ -584,7 +552,6 @@ export function ComposeOverlay({
|
||||
}, [
|
||||
handleRecordIntent,
|
||||
handleTextIntent,
|
||||
handleTaskIntent,
|
||||
handleStopIntent,
|
||||
handleCancelIntent,
|
||||
handleSendIntent,
|
||||
@@ -597,24 +564,13 @@ export function ComposeOverlay({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const currentStep = stepRef.current;
|
||||
|
||||
// Consume a key compose handles so it never reaches the stream's
|
||||
// window-level navigation/action handlers. Without this, e.g. Escape
|
||||
// while recording or reviewing would both cancel compose and exit the
|
||||
// stream. Listening in the capture phase (see below) guarantees compose
|
||||
// sees the key before those handlers regardless of registration order.
|
||||
const consume = () => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
if (
|
||||
currentStep === 'typing' ||
|
||||
currentStep === 'task' ||
|
||||
currentStep === 'configuring' ||
|
||||
currentStep === 'picking'
|
||||
) {
|
||||
if (e.key === 'Escape') {
|
||||
consume();
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
return;
|
||||
@@ -632,20 +588,17 @@ export function ComposeOverlay({
|
||||
switch (currentStep) {
|
||||
case 'idle': {
|
||||
if (e.key === '`' && !e.repeat) {
|
||||
consume();
|
||||
e.preventDefault();
|
||||
handleRecordIntent();
|
||||
} else if (e.key === 's' || e.key === 'S') {
|
||||
consume();
|
||||
e.preventDefault();
|
||||
if (!guardIdle()) break;
|
||||
if (!requireDesktop('Screen recording')) break;
|
||||
setRecordingSource('screen');
|
||||
setStepSync('picking');
|
||||
} else if (e.key === 't' || e.key === 'T') {
|
||||
consume();
|
||||
e.preventDefault();
|
||||
handleTextIntent();
|
||||
} else if (e.key === 'd' || e.key === 'D') {
|
||||
consume();
|
||||
handleTaskIntent();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -653,17 +606,17 @@ export function ComposeOverlay({
|
||||
case 'recording': {
|
||||
if (e.key === '`' && !e.repeat) {
|
||||
// Second tap stops media recording (toggle mode)
|
||||
consume();
|
||||
e.preventDefault();
|
||||
handleStopIntent();
|
||||
} else if (
|
||||
(e.key === 's' || e.key === 'S') &&
|
||||
recordingSourceRef.current === 'screen'
|
||||
) {
|
||||
// S stops screen recording when main window is focused
|
||||
consume();
|
||||
e.preventDefault();
|
||||
handleStopIntent();
|
||||
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||
consume();
|
||||
e.preventDefault();
|
||||
handleCancelIntent();
|
||||
}
|
||||
break;
|
||||
@@ -671,10 +624,10 @@ export function ComposeOverlay({
|
||||
|
||||
case 'reviewing': {
|
||||
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||
consume();
|
||||
e.preventDefault();
|
||||
handleCancelIntent();
|
||||
} else if (e.key === 'Enter') {
|
||||
consume();
|
||||
e.preventDefault();
|
||||
handleSendIntent();
|
||||
}
|
||||
break;
|
||||
@@ -701,13 +654,10 @@ export function ComposeOverlay({
|
||||
}
|
||||
};
|
||||
|
||||
// Capture phase so compose can consume keys before the stream's
|
||||
// window-level navigation/action handlers (which listen in the bubble
|
||||
// phase) see them.
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [
|
||||
@@ -716,7 +666,6 @@ export function ComposeOverlay({
|
||||
guardIdle,
|
||||
handleRecordIntent,
|
||||
handleTextIntent,
|
||||
handleTaskIntent,
|
||||
handleStopIntent,
|
||||
handleCancelIntent,
|
||||
handleSendIntent,
|
||||
@@ -770,7 +719,7 @@ export function ComposeOverlay({
|
||||
/>
|
||||
)}
|
||||
{step === 'recording' && recordingSource === 'screen' && (
|
||||
<div className="bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center">
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
||||
<div className="absolute top-8 z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
||||
@@ -825,25 +774,16 @@ export function ComposeOverlay({
|
||||
dropZoneProps={dropZoneProps}
|
||||
/>
|
||||
)}
|
||||
{step === 'task' && (
|
||||
<TaskComposeStep
|
||||
networkId={networkId}
|
||||
onCancel={cancel}
|
||||
onSubmit={(properties) =>
|
||||
handleArtifactSubmit({ type: 'task', properties })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!targetPath && step === 'configuring' && (
|
||||
<ConfigureContainerStep
|
||||
<ConfigureStreamStep
|
||||
networkId={networkId}
|
||||
onCancel={cancel}
|
||||
onSubmit={handleStreamSubmit}
|
||||
/>
|
||||
)}
|
||||
{step === 'submitting' && (
|
||||
<div className="bg-background/95 absolute inset-0 z-50 flex flex-col items-center justify-center backdrop-blur-sm">
|
||||
<span className="text-muted-foreground animate-pulse text-sm">
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
||||
<span className="animate-pulse text-sm text-white/60">
|
||||
Sending...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+15
-20
@@ -10,20 +10,17 @@ import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
interface ConfigureContainerStepProps {
|
||||
/** Drives labels and hints; the form is identical for both kinds. */
|
||||
kind?: 'stream' | 'folder';
|
||||
interface ConfigureStreamStepProps {
|
||||
networkId: string | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (name: string, visibleTo: string[]) => void;
|
||||
onSubmit: (streamName: string, visibleTo: string[]) => void;
|
||||
}
|
||||
|
||||
export function ConfigureContainerStep({
|
||||
kind = 'stream',
|
||||
export function ConfigureStreamStep({
|
||||
networkId,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ConfigureContainerStepProps) {
|
||||
}: ConfigureStreamStepProps) {
|
||||
const { data: networks } = useNetworks();
|
||||
const network = networks?.find((n) => n.id === networkId);
|
||||
|
||||
@@ -77,15 +74,14 @@ export function ConfigureContainerStep({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
|
||||
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">
|
||||
{/* Stream name */}
|
||||
<div>
|
||||
<Label className="text-muted-foreground mb-1 text-xs">
|
||||
{kind === 'folder' ? 'Folder name' : 'Stream name'}
|
||||
</Label>
|
||||
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
@@ -94,22 +90,21 @@ export function ConfigureContainerStep({
|
||||
setName(e.target.value);
|
||||
}}
|
||||
placeholder="Give it a name..."
|
||||
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<Label className="text-muted-foreground mb-1 text-xs">
|
||||
Visible to
|
||||
</Label>
|
||||
<div className="border-border rounded-md border">
|
||||
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
|
||||
<div className="rounded-md border border-white/10">
|
||||
{/* Everyone in network */}
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => setEveryone((prev) => !prev)}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
||||
'hover:bg-accent',
|
||||
'text-white/70 hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
@@ -138,7 +133,7 @@ export function ConfigureContainerStep({
|
||||
onClick={() => toggleMember(member.id)}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors',
|
||||
'hover:bg-accent',
|
||||
'text-white/70 hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
@@ -146,7 +141,7 @@ export function ConfigureContainerStep({
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="flex-1 truncate">
|
||||
@@ -163,14 +158,14 @@ export function ConfigureContainerStep({
|
||||
</div>
|
||||
|
||||
{/* Keyboard hints */}
|
||||
<div className="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
|
||||
<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 ${kind} (or press ${metaKey}+Enter)`}
|
||||
title={`Create stream (or press ${metaKey}+Enter)`}
|
||||
>
|
||||
create
|
||||
</KeyHint>
|
||||
@@ -1,14 +1,13 @@
|
||||
/* Theme Milkdown Crepe to the app's design tokens so it follows light/dark.
|
||||
* Crepe's theme is a palette driven by --crepe-* custom properties; we map
|
||||
* those onto our CSS variables (globals.css) so the editor (and the read-only
|
||||
* display, which uses the same engine) inherits the active theme and sits on
|
||||
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces.
|
||||
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom
|
||||
* properties; we override those to white-on-transparent with a blue accent so
|
||||
* the editor (and the read-only display, which uses the same engine) sits on
|
||||
* top of the existing card instead of painting its own opaque background.
|
||||
*
|
||||
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled
|
||||
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those
|
||||
* selectors too, otherwise they fall back to transparent and the menu is
|
||||
* unreadable over the content behind it. Our tokens resolve against the
|
||||
* :root/.dark cascade on <html>, so the portaled menus stay themed too. */
|
||||
* unreadable over the content behind it. */
|
||||
.llink-crepe .milkdown,
|
||||
.milkdown-slash-menu,
|
||||
.milkdown-toolbar,
|
||||
@@ -16,30 +15,24 @@
|
||||
.milkdown-link-preview,
|
||||
.milkdown-block-handle {
|
||||
--crepe-color-background: transparent;
|
||||
--crepe-color-on-background: var(--card-foreground);
|
||||
/* Surface backs code blocks, tables, and the floating menus — use an opaque
|
||||
* themed surface so menus read clearly over content. */
|
||||
--crepe-color-surface: var(--popover);
|
||||
--crepe-color-surface-low: var(--muted);
|
||||
--crepe-color-on-surface: var(--popover-foreground);
|
||||
--crepe-color-on-surface-variant: var(--muted-foreground);
|
||||
--crepe-color-outline: var(--border);
|
||||
--crepe-color-primary: var(--primary);
|
||||
--crepe-color-secondary: color-mix(in oklch, var(--primary) 22%, transparent);
|
||||
--crepe-color-on-secondary: var(--foreground);
|
||||
--crepe-color-inverse: var(--foreground);
|
||||
--crepe-color-on-inverse: var(--background);
|
||||
--crepe-color-inline-code: var(--primary);
|
||||
--crepe-color-inline-area: var(--muted);
|
||||
--crepe-color-error: var(--destructive);
|
||||
--crepe-color-hover: var(--accent);
|
||||
--crepe-color-selected: color-mix(in oklch, var(--primary) 22%, transparent);
|
||||
/* frame-dark ships white "glow" shadows that vanish on light surfaces;
|
||||
* use a conventional dark drop shadow so floating menus lift in both themes. */
|
||||
--crepe-shadow-1:
|
||||
0px 1px 2px 0px rgb(0 0 0 / 0.12), 0px 1px 3px 1px rgb(0 0 0 / 0.08);
|
||||
--crepe-shadow-2:
|
||||
0px 1px 2px 0px rgb(0 0 0 / 0.16), 0px 2px 6px 2px rgb(0 0 0 / 0.12);
|
||||
--crepe-color-on-background: rgb(255 255 255 / 0.92);
|
||||
/* Surface backs code blocks, tables, and the floating menus — keep it
|
||||
* (near-)opaque so menus read clearly over content. */
|
||||
--crepe-color-surface: rgb(24 24 28 / 0.96);
|
||||
--crepe-color-surface-low: rgb(42 42 50 / 0.96);
|
||||
--crepe-color-on-surface: #ffffff;
|
||||
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.65);
|
||||
--crepe-color-outline: rgb(255 255 255 / 0.2);
|
||||
--crepe-color-primary: #60a5fa;
|
||||
--crepe-color-secondary: rgb(96 165 250 / 0.25);
|
||||
--crepe-color-on-secondary: #ffffff;
|
||||
--crepe-color-inverse: #ffffff;
|
||||
--crepe-color-on-inverse: #0b0b0b;
|
||||
--crepe-color-inline-code: #fca5a5;
|
||||
--crepe-color-inline-area: rgb(255 255 255 / 0.1);
|
||||
--crepe-color-error: #f87171;
|
||||
--crepe-color-hover: rgb(255 255 255 / 0.08);
|
||||
--crepe-color-selected: rgb(96 165 250 / 0.25);
|
||||
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
|
||||
--crepe-font-default: inherit;
|
||||
--crepe-font-title: inherit;
|
||||
@@ -64,66 +57,6 @@
|
||||
.llink-crepe .milkdown .ProseMirror {
|
||||
padding: 0;
|
||||
outline: none;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
/* 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: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.llink-crepe .milkdown .ProseMirror > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Image *links* render through the base commonmark schema (the ImageBlock
|
||||
* feature is off — see markdown-editor.tsx). Keep them within the content
|
||||
* column and softly rounded. */
|
||||
.llink-crepe .milkdown .ProseMirror img {
|
||||
max-width: 100%;
|
||||
max-height: 420px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Editing context: fill the compose card and scroll internally so a long
|
||||
@@ -138,10 +71,8 @@
|
||||
}
|
||||
|
||||
/* Pad the content (not the card) so the slash menu — which Crepe appends to
|
||||
* .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. */
|
||||
* .milkdown — can use the full card width/height before clipping. */
|
||||
.llink-crepe--fill .milkdown .ProseMirror {
|
||||
min-height: 100%;
|
||||
padding: var(--message-card-padding) var(--message-editor-gutter);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Crepe } from '@milkdown/crepe';
|
||||
import { editorViewCtx, editorViewOptionsCtx } 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';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { transferFiles } from '@/lib/data-transfer';
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
/** Initial markdown. The editor owns its content after mount; edits flow out
|
||||
@@ -58,11 +55,6 @@ export function MarkdownEditor({
|
||||
[Crepe.Feature.BlockEdit]: !readOnly,
|
||||
[Crepe.Feature.Toolbar]: !readOnly,
|
||||
[Crepe.Feature.Placeholder]: !readOnly,
|
||||
// Off on purpose: file uploads aren't supported, so this feature's
|
||||
// paste/drop handler would only strand an "upload in progress" node in
|
||||
// the editor. Image *links* still render through the base commonmark
|
||||
// schema, and pasted image files are routed to the compose attachment
|
||||
// strip (see use-file-input).
|
||||
[Crepe.Feature.ImageBlock]: false,
|
||||
[Crepe.Feature.Latex]: false,
|
||||
[Crepe.Feature.TopBar]: false,
|
||||
@@ -81,21 +73,6 @@ export function MarkdownEditor({
|
||||
onChangeRef.current?.(markdown);
|
||||
});
|
||||
});
|
||||
|
||||
// Decline dropped files: they belong in the compose attachment strip
|
||||
// (the drop zone still receives them), not inlined into the document.
|
||||
// Without this the editor parses the drag's HTML into an image node with
|
||||
// an ephemeral blob:/localhost src, which then leaks into the markdown.
|
||||
crepe.editor.config((ctx) => {
|
||||
ctx.update(editorViewOptionsCtx, (prev) => ({
|
||||
...prev,
|
||||
handleDrop: (view, event, slice, moved) => {
|
||||
const data = event.dataTransfer;
|
||||
if (data && transferFiles(data).length > 0) return true;
|
||||
return prev.handleDrop?.(view, event, slice, moved) ?? false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
crepe.create().then(() => {
|
||||
@@ -105,18 +82,7 @@ export function MarkdownEditor({
|
||||
}
|
||||
created = crepe;
|
||||
if (autoFocus && !readOnly) {
|
||||
// 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();
|
||||
});
|
||||
root.querySelector<HTMLElement>('.ProseMirror')?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -131,11 +97,7 @@ export function MarkdownEditor({
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
'llink-crepe',
|
||||
!readOnly && 'llink-crepe--fill scrollbar-card',
|
||||
className,
|
||||
)}
|
||||
className={cn('llink-crepe', !readOnly && 'llink-crepe--fill', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Paperclip } from 'lucide-react';
|
||||
import type { RecordingMode } from '@/stores/media-settings-store';
|
||||
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
||||
@@ -8,7 +7,6 @@ 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 } from '@/lib/constants';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
@@ -36,41 +34,16 @@ interface RecordingOverlayProps {
|
||||
objectFit?: 'cover' | 'contain';
|
||||
}
|
||||
|
||||
function useRecordingCountdown(active: boolean) {
|
||||
function RecordingTimer() {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
let toastSent = false;
|
||||
const start = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const seconds = Math.floor((Date.now() - start) / 1000);
|
||||
setElapsed(seconds);
|
||||
|
||||
if (seconds >= RECORDING_MAX_DURATION_SECONDS && !toastSent) {
|
||||
toast.warning(
|
||||
'That is a long recording, cancel and re-record with your distilled thoughts',
|
||||
);
|
||||
toastSent = true;
|
||||
}
|
||||
setElapsed((prev) => prev + 1);
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
setElapsed(0);
|
||||
};
|
||||
}, [active]);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { elapsed, isWarning: elapsed >= RECORDING_MAX_DURATION_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')}`;
|
||||
@@ -78,14 +51,7 @@ 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={cn(
|
||||
'font-mono text-sm text-white/80',
|
||||
isWarning && 'text-red-400',
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-white/80">{display}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,31 +137,14 @@ export function RecordingOverlay({
|
||||
const isLoading = isRecording && !mediaStream;
|
||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||
|
||||
const { elapsed, isWarning } = useRecordingCountdown(
|
||||
isRecording && !isLoading,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center',
|
||||
'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">
|
||||
@@ -230,13 +179,13 @@ export function RecordingOverlay({
|
||||
|
||||
{/* Bottom gradient scrim for keyboard hint readability */}
|
||||
{(isRecording || isReviewing) && !isLoading && (
|
||||
<div className="from-scrim/60 pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t to-transparent" />
|
||||
<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" />
|
||||
)}
|
||||
|
||||
{/* Top center: recording indicator */}
|
||||
<div className="absolute top-8 z-10">
|
||||
{isRecording && !isLoading ? (
|
||||
<RecordingTimer elapsed={elapsed} isWarning={isWarning} />
|
||||
<RecordingTimer />
|
||||
) : isReviewing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-white/80">Review recording</span>
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
||||
<div>
|
||||
<Label className="text-muted-foreground mb-1 text-xs">Task</Label>
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="What needs to get done?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground mb-1 text-xs">Notes</Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Optional details…"
|
||||
className="min-h-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground mb-1 text-xs">
|
||||
Assign to
|
||||
</Label>
|
||||
<Select value={assignedTo} onValueChange={setAssignedTo}>
|
||||
<SelectTrigger className="w-full">
|
||||
<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="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
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';
|
||||
@@ -37,6 +35,12 @@ 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,
|
||||
@@ -46,9 +50,7 @@ export function TextEditor({
|
||||
attachmentProps,
|
||||
}: TextEditorProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
// 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 [forceCardMode, setForceCardMode] = useState(false);
|
||||
|
||||
const [debouncedText, setDebouncedText] = useState(textContent);
|
||||
useEffect(() => {
|
||||
@@ -59,16 +61,13 @@ export function TextEditor({
|
||||
|
||||
const attachmentCount = attachmentProps?.attachments.length ?? 0;
|
||||
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
|
||||
const shouldCard =
|
||||
!carded &&
|
||||
(textContent.length >= IMMERSIVE_CHAR_LIMIT ||
|
||||
hasEnrichments ||
|
||||
hasMarkdownFormatting(textContent));
|
||||
if (shouldCard) setCarded(true);
|
||||
const immersive = !carded;
|
||||
const immersive =
|
||||
textContent.length < IMMERSIVE_CHAR_LIMIT &&
|
||||
!hasEnrichments &&
|
||||
!forceCardMode;
|
||||
|
||||
// Keep the immersive textarea focused with the caret at the end on mount.
|
||||
// The card-mode editor manages its own focus.
|
||||
// Keep the immersive textarea focused with the caret at the end when we
|
||||
// (re)enter it. The card-mode editor manages its own focus.
|
||||
useEffect(() => {
|
||||
if (!immersive) return;
|
||||
const t = setTimeout(() => {
|
||||
@@ -96,7 +95,7 @@ export function TextEditor({
|
||||
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCarded(true);
|
||||
setForceCardMode(true);
|
||||
}
|
||||
},
|
||||
[onCancel, onSubmit, textContent],
|
||||
@@ -112,7 +111,7 @@ export function TextEditor({
|
||||
);
|
||||
|
||||
const keyboardHints = (
|
||||
<div className="text-muted-foreground absolute bottom-4 flex items-center gap-4 text-sm">
|
||||
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
||||
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
||||
cancel
|
||||
</KeyHint>
|
||||
@@ -128,7 +127,7 @@ export function TextEditor({
|
||||
{immersive && (
|
||||
<KeyHint
|
||||
keys={`${metaKey}+M`}
|
||||
onClick={() => setCarded(true)}
|
||||
onClick={() => setForceCardMode(true)}
|
||||
title={`Switch to markdown editor (or press ${metaKey}+M)`}
|
||||
>
|
||||
markdown
|
||||
@@ -161,8 +160,8 @@ export function TextEditor({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
||||
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
>
|
||||
@@ -174,7 +173,7 @@ export function TextEditor({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
className={cn(
|
||||
'text-foreground placeholder-muted-foreground w-full resize-none border-none bg-transparent p-8 text-center outline-none',
|
||||
'w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none',
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
@@ -189,13 +188,13 @@ export function TextEditor({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
||||
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
>
|
||||
<div className="bg-card text-card-foreground border-border animate-in fade-in zoom-in-95 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 duration-200">
|
||||
<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">
|
||||
{/* 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. */}
|
||||
@@ -208,7 +207,7 @@ export function TextEditor({
|
||||
</div>
|
||||
|
||||
{hasEnrichments && strip && (
|
||||
<div className="border-border shrink-0 border-t px-5 py-3">
|
||||
<div className="shrink-0 border-t border-white/10 px-5 py-3">
|
||||
{strip}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,12 +16,30 @@ 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 { getParticleDisplayName } from '@/lib/particle-display';
|
||||
import type { Particle } from '@/api/types';
|
||||
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);
|
||||
|
||||
@@ -1,15 +1,126 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { CircleDot, CircleCheckBig } from 'lucide-react';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { ContainerView } from '@/features/particles/container-view';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Route-level component for /:networkId (index). The network root behaves
|
||||
* like a folder: a container of streams, folders, and loose particles.
|
||||
* Route-level component for /:networkId (index).
|
||||
* Shows root-level particles for the selected network.
|
||||
*/
|
||||
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, []);
|
||||
|
||||
return <ContainerView 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="1–9">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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ 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';
|
||||
@@ -46,11 +45,11 @@ function MemberRow({
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||
<HumanAvatar
|
||||
avatarObjectId={human.avatar_object_id}
|
||||
initials={initials}
|
||||
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||
/>
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { OnboardingKeyboard } from './onboarding-keyboard';
|
||||
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
|
||||
import { useHoldGesture } from './use-hold-gesture';
|
||||
|
||||
/**
|
||||
* Visible hold duration for the lesson. Deliberately longer than the real
|
||||
* HOLD_THRESHOLD_MS (which is imperceptible) so the gesture is teachable.
|
||||
*/
|
||||
const ONBOARDING_HOLD_MS = 600;
|
||||
|
||||
export function HoldStep({ onAdvance }: { onAdvance: () => void }) {
|
||||
const { state, progress } = useHoldGesture({
|
||||
targetKey: '`',
|
||||
targetCode: 'Backquote',
|
||||
holdMs: ONBOARDING_HOLD_MS,
|
||||
});
|
||||
const done = state === 'success';
|
||||
|
||||
useEffect(() => {
|
||||
if (!done) return;
|
||||
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [done, onAdvance]);
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
title={done ? 'That is push-to-talk' : 'Hold to record'}
|
||||
subtitle={
|
||||
done
|
||||
? 'Hold ` whenever you want to speak, release when you are done.'
|
||||
: 'Press and hold the ` key, like a walkie-talkie.'
|
||||
}
|
||||
status={
|
||||
done ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="size-4" /> Nice.
|
||||
</span>
|
||||
) : state === 'holding' ? (
|
||||
'Keep holding…'
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<OnboardingKeyboard
|
||||
highlightKey="`"
|
||||
state={done ? 'success' : state === 'holding' ? 'active' : 'idle'}
|
||||
progress={progress}
|
||||
/>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type KeyState = 'idle' | 'active' | 'success';
|
||||
|
||||
interface OnboardingKeyboardProps {
|
||||
/** The key to emphasise. Defaults to the record key. */
|
||||
highlightKey?: string;
|
||||
state: KeyState;
|
||||
/** Hold progress (0–1), fills the emphasised key from the bottom. */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface KeyCapProps {
|
||||
label: string;
|
||||
hero?: boolean;
|
||||
state?: KeyState;
|
||||
progress?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function KeyCap({
|
||||
label,
|
||||
hero,
|
||||
state = 'idle',
|
||||
progress = 0,
|
||||
className,
|
||||
}: KeyCapProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex select-none items-center justify-center overflow-hidden rounded-lg border text-sm font-medium',
|
||||
hero ? 'h-14 w-14 text-xl' : 'h-14',
|
||||
!hero && 'border-border bg-muted text-muted-foreground/70',
|
||||
hero &&
|
||||
state === 'idle' &&
|
||||
'border-primary/40 bg-primary/10 text-foreground motion-safe:animate-pulse',
|
||||
hero &&
|
||||
state === 'active' &&
|
||||
'border-primary/60 bg-primary/20 text-foreground',
|
||||
hero &&
|
||||
state === 'success' &&
|
||||
'border-emerald-500/50 bg-emerald-500/15 text-emerald-700 dark:text-emerald-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hero && state !== 'success' && progress > 0 && (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 bg-primary/25"
|
||||
style={{ height: `${progress * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the record key in its physical context: Esc above, 1 to its right, and
|
||||
* Tab / Q on the row below — so the key is recognisable on a real keyboard.
|
||||
*/
|
||||
export function OnboardingKeyboard({
|
||||
highlightKey = '`',
|
||||
state,
|
||||
progress = 0,
|
||||
}: OnboardingKeyboardProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<KeyCap label="esc" className="w-14 text-xs" />
|
||||
<div className="flex gap-1.5">
|
||||
<KeyCap label={highlightKey} hero state={state} progress={progress} />
|
||||
<KeyCap label="1" className="w-14" />
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<KeyCap label="tab" className="w-20 text-xs" />
|
||||
<KeyCap label="Q" className="w-12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { OnboardingKeyboard } from './onboarding-keyboard';
|
||||
import { OnboardingStep } from './onboarding-step';
|
||||
import { HoldStep } from './hold-step';
|
||||
import { ToggleStep } from './toggle-step';
|
||||
import { TapStep } from './tap-step';
|
||||
import { PermissionStep } from './permission-step';
|
||||
|
||||
const STEPS = ['welcome', 'hold', 'tap', 'toggle', 'permission'] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
/**
|
||||
* First-run wizard that teaches the record key. Rendered as a full-screen
|
||||
* takeover (above PusherProvider) so none of the stream/compose keyboard
|
||||
* handlers are mounted to compete for the same keys.
|
||||
*/
|
||||
export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) {
|
||||
const [step, setStep] = useState<Step>('welcome');
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const index = STEPS.indexOf(step);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setStep((cur) => STEPS[Math.min(STEPS.indexOf(cur) + 1, STEPS.length - 1)]);
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setStep((cur) => STEPS[Math.max(0, STEPS.indexOf(cur) - 1)]);
|
||||
}, []);
|
||||
|
||||
// Focus the panel on the gesture steps so window-level key handling has a
|
||||
// home; the static steps autofocus their primary button instead, which lets
|
||||
// Enter activate it natively (no duplicate window handler).
|
||||
useEffect(() => {
|
||||
if (step === 'hold' || step === 'toggle' || step === 'tap') {
|
||||
panelRef.current?.focus();
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
// Esc skips the wizard from anywhere; ← (and Backspace) steps back.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onComplete();
|
||||
} else if (e.key === 'Enter' && step === 'welcome') {
|
||||
e.preventDefault();
|
||||
goNext();
|
||||
} else if (
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'Backspace' ||
|
||||
e.key === 'Delete'
|
||||
) {
|
||||
e.preventDefault();
|
||||
goBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onComplete, goNext, step, goBack]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<div className="drag-region flex items-center justify-between border-b border-border px-3 py-1">
|
||||
<WindowControls />
|
||||
<KeyHint
|
||||
keys="Esc"
|
||||
onClick={onComplete}
|
||||
title="Skip onboarding (or press Esc)"
|
||||
className="no-drag text-xs text-muted-foreground"
|
||||
>
|
||||
to skip
|
||||
</KeyHint>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label="Welcome to llink"
|
||||
className="flex w-full max-w-md flex-col items-center gap-8 rounded-2xl border border-border bg-card p-10 outline-none"
|
||||
>
|
||||
{step === 'welcome' && (
|
||||
<OnboardingStep
|
||||
title="Welcome to llink"
|
||||
subtitle="To use llink, you must learn the keyboard shortcuts. Don't worry, once you learn them, you'll feel like your flowing."
|
||||
>
|
||||
<OnboardingKeyboard highlightKey="`" state="idle" />
|
||||
</OnboardingStep>
|
||||
)}
|
||||
{step === 'hold' && <HoldStep onAdvance={goNext} />}
|
||||
{step === 'toggle' && <ToggleStep onAdvance={goNext} />}
|
||||
{step === 'tap' && <TapStep onAdvance={goNext} />}
|
||||
{step === 'permission' && <PermissionStep onAdvance={onComplete} />}
|
||||
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{STEPS.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all',
|
||||
i === index
|
||||
? 'w-6 bg-foreground'
|
||||
: i < index
|
||||
? 'w-1.5 bg-foreground/40'
|
||||
: 'w-1.5 bg-foreground/15',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-5 items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||
{index > 0 && (
|
||||
<KeyHint
|
||||
keys="←"
|
||||
onClick={goBack}
|
||||
title="Back (or press ←)"
|
||||
aria-label="Previous step"
|
||||
>
|
||||
back
|
||||
</KeyHint>
|
||||
)}
|
||||
{step === 'welcome' && (
|
||||
<KeyHint
|
||||
keys="Enter"
|
||||
onClick={goNext}
|
||||
title="Continue (or press Enter)"
|
||||
>
|
||||
continue
|
||||
</KeyHint>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** How long a success state lingers before the wizard auto-advances, in ms. */
|
||||
export const SUCCESS_DWELL_MS = 2000;
|
||||
|
||||
interface OnboardingStepProps {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
/** The interactive body (keyboard diagram, toggle, etc.). */
|
||||
children?: ReactNode;
|
||||
/** Live feedback line, announced to screen readers. */
|
||||
status?: ReactNode;
|
||||
/** Actions and hints below the body. */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/** Shared layout so every onboarding step keeps the same vertical rhythm. */
|
||||
export function OnboardingStep({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
status,
|
||||
footer,
|
||||
}: OnboardingStepProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mx-auto max-w-xs text-sm text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children && (
|
||||
<div className="flex flex-col items-center gap-4 py-2">{children}</div>
|
||||
)}
|
||||
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
||||
{status}
|
||||
</p>
|
||||
{footer && (
|
||||
<div className="flex flex-col items-center gap-3">{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
|
||||
import { useMediaDevices } from '@/hooks/use-media-devices';
|
||||
|
||||
export function PermissionStep({ onAdvance }: { onAdvance: () => void }) {
|
||||
const { permissionState, requestLabels } = useMediaDevices();
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const granted = permissionState === 'granted';
|
||||
const denied = permissionState === 'denied';
|
||||
|
||||
useEffect(() => {
|
||||
if (!granted) return;
|
||||
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [granted, onAdvance]);
|
||||
|
||||
const handleAllow = async () => {
|
||||
setRequesting(true);
|
||||
await requestLabels();
|
||||
setRequesting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
title="Allow mic and camera"
|
||||
subtitle="llink records audio and video. Grant access once so recording just works."
|
||||
status={
|
||||
granted ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="size-4" /> Access granted.
|
||||
</span>
|
||||
) : denied ? (
|
||||
<span className="text-muted-foreground">
|
||||
No access yet. You can enable it later in System Settings.
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
footer={
|
||||
granted ? null : denied ? (
|
||||
<Button variant="secondary" onClick={onAdvance} autoFocus>
|
||||
Continue anyway
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleAllow} disabled={requesting} autoFocus>
|
||||
{requesting ? 'Requesting…' : 'Allow access'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { OnboardingKeyboard } from './onboarding-keyboard';
|
||||
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
|
||||
import { useTapGesture } from './use-tap-gesture';
|
||||
|
||||
export function TapStep({ onAdvance }: { onAdvance: () => void }) {
|
||||
const { taps, satisfied } = useTapGesture({
|
||||
targetKey: '`',
|
||||
targetCode: 'Backquote',
|
||||
taps: 2,
|
||||
});
|
||||
const recording = taps === 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!satisfied) return;
|
||||
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [satisfied, onAdvance]);
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
title={
|
||||
satisfied
|
||||
? 'That is tap to toggle'
|
||||
: recording
|
||||
? 'Recording…'
|
||||
: 'Tap to start and stop'
|
||||
}
|
||||
subtitle={
|
||||
satisfied
|
||||
? 'A quick tap starts recording, another tap finishes it.'
|
||||
: recording
|
||||
? 'Now tap ` again to stop.'
|
||||
: 'Prefer not to hold? Tap ` once to start recording.'
|
||||
}
|
||||
status={
|
||||
satisfied ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="size-4" /> Done.
|
||||
</span>
|
||||
) : recording ? (
|
||||
<span className="inline-flex items-center gap-2 text-muted-foreground">
|
||||
<span className="size-2.5 rounded-full bg-red-500 motion-safe:animate-pulse" />
|
||||
Recording
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<OnboardingKeyboard
|
||||
highlightKey="`"
|
||||
state={satisfied ? 'success' : recording ? 'active' : 'idle'}
|
||||
/>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Check, Mic, Video } from 'lucide-react';
|
||||
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
|
||||
import { useTapGesture } from './use-tap-gesture';
|
||||
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
|
||||
export function ToggleStep({ onAdvance }: { onAdvance: () => void }) {
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||
|
||||
const { satisfied } = useTapGesture({
|
||||
targetKey: 'v',
|
||||
onTap: () => {
|
||||
const current = useMediaSettingsStore.getState().recordingMode;
|
||||
setRecordingMode(current === 'video' ? 'audio' : 'video');
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!satisfied) return;
|
||||
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [satisfied, onAdvance]);
|
||||
|
||||
// Teaching the toggle shouldn't permanently change the user's default mode.
|
||||
useEffect(() => {
|
||||
const original = useMediaSettingsStore.getState().recordingMode;
|
||||
return () => setRecordingMode(original);
|
||||
}, [setRecordingMode]);
|
||||
|
||||
const isVideo = recordingMode === 'video';
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
title={satisfied ? 'Audio or video, your call' : 'Switch audio and video'}
|
||||
subtitle={
|
||||
satisfied
|
||||
? 'Tap V before recording to choose how you show up.'
|
||||
: 'Tap the V key to switch between video and audio.'
|
||||
}
|
||||
status={
|
||||
satisfied ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="size-4" />{' '}
|
||||
{isVideo ? 'Back to video.' : 'Audio it is.'}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="flex size-32 flex-col items-center justify-center gap-2 rounded-2xl border border-border bg-muted text-foreground">
|
||||
{isVideo ? <Video className="size-9" /> : <Mic className="size-9" />}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isVideo ? 'Video' : 'Audio'}
|
||||
</span>
|
||||
</div>
|
||||
<VideoAudioToggle />
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isTypingTarget } from '@/lib/keyboard';
|
||||
|
||||
export type HoldState = 'idle' | 'holding' | 'success';
|
||||
|
||||
interface UseHoldGestureOptions {
|
||||
/** Detected against KeyboardEvent.key. */
|
||||
targetKey: string;
|
||||
/** Optional KeyboardEvent.code fallback (e.g. 'Backquote'). */
|
||||
targetCode?: string;
|
||||
/** How long the key must be held before it counts, in ms. */
|
||||
holdMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects a deliberate press-and-hold of a single key. Success is driven by a
|
||||
* timer started on keydown rather than by keyup, so it still resolves if the OS
|
||||
* swallows the keyup (e.g. macOS press-and-hold accent popover). Releasing early
|
||||
* simply resets — a tutorial never fails the user.
|
||||
*/
|
||||
export function useHoldGesture({
|
||||
targetKey,
|
||||
targetCode,
|
||||
holdMs,
|
||||
}: UseHoldGestureOptions) {
|
||||
const [state, setState] = useState<HoldState>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let rafId = 0;
|
||||
let startedAt = 0;
|
||||
let done = false;
|
||||
|
||||
const matches = (e: KeyboardEvent) =>
|
||||
e.key === targetKey || (targetCode != null && e.code === targetCode);
|
||||
|
||||
const tick = () => {
|
||||
const next = Math.min((Date.now() - startedAt) / holdMs, 1);
|
||||
setProgress(next);
|
||||
if (next >= 1) {
|
||||
done = true;
|
||||
setState('success');
|
||||
return;
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (done || e.repeat || isTypingTarget(e) || !matches(e)) return;
|
||||
e.preventDefault();
|
||||
startedAt = Date.now();
|
||||
setState('holding');
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (done || !matches(e)) return;
|
||||
cancelAnimationFrame(rafId);
|
||||
startedAt = 0;
|
||||
setProgress(0);
|
||||
setState('idle');
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [targetKey, targetCode, holdMs]);
|
||||
|
||||
return { state, progress };
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isTypingTarget } from '@/lib/keyboard';
|
||||
|
||||
interface UseTapGestureOptions {
|
||||
/** Detected against KeyboardEvent.key. */
|
||||
targetKey: string;
|
||||
/** Optional KeyboardEvent.code fallback (e.g. 'Backquote'). */
|
||||
targetCode?: string;
|
||||
/** Taps required before `satisfied` flips true. Defaults to 1. */
|
||||
taps?: number;
|
||||
/** Fires on each counted tap, with the new running count. */
|
||||
onTap?: (count: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts discrete taps of a single key, ignoring auto-repeat. Used both for the
|
||||
* one-tap audio/video toggle and the two-tap start/stop demonstration.
|
||||
*/
|
||||
export function useTapGesture({
|
||||
targetKey,
|
||||
targetCode,
|
||||
taps = 1,
|
||||
onTap,
|
||||
}: UseTapGestureOptions) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const onTapRef = useRef(onTap);
|
||||
useEffect(() => {
|
||||
onTapRef.current = onTap;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const matches = (e: KeyboardEvent) =>
|
||||
e.key === targetKey || (targetCode != null && e.code === targetCode);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.repeat || isTypingTarget(e) || !matches(e)) return;
|
||||
e.preventDefault();
|
||||
setCount((prev) => {
|
||||
if (prev >= taps) return prev;
|
||||
const next = prev + 1;
|
||||
onTapRef.current?.(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [targetKey, targetCode, taps]);
|
||||
|
||||
return { taps: count, satisfied: count >= taps };
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
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="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
|
||||
<KeyHint keys="1–9">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,25 +2,21 @@ import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
|
||||
import { softDeleteParticle } from '@/lib/firestore-particles';
|
||||
import {
|
||||
particlePath,
|
||||
parseParticlePath,
|
||||
toFirestoreDocPath,
|
||||
type ParticlePath,
|
||||
} from '@/lib/particle-path';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
interface DeleteParticleOverlayProps {
|
||||
/** Path of the stream the particle lives in — may be nested. */
|
||||
streamPath: ParticlePath;
|
||||
networkId: string;
|
||||
streamId: string;
|
||||
particle: Particle;
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DeleteParticleOverlay({
|
||||
streamPath,
|
||||
networkId,
|
||||
streamId,
|
||||
particle,
|
||||
userId,
|
||||
onClose,
|
||||
@@ -33,9 +29,8 @@ export function DeleteParticleOverlay({
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const { networkId, segments } = parseParticlePath(streamPath);
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [...segments, particle.id]),
|
||||
particlePath(networkId, [streamId, particle.id]),
|
||||
);
|
||||
await softDeleteParticle(docPath, userId);
|
||||
toast.success('Particle deleted');
|
||||
@@ -46,7 +41,7 @@ export function DeleteParticleOverlay({
|
||||
toast.error(message);
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [deleting, onClose, particle.id, streamPath, userId]);
|
||||
}, [deleting, networkId, onClose, particle.id, streamId, userId]);
|
||||
|
||||
return (
|
||||
<ConfirmDestructiveOverlay
|
||||
|
||||
@@ -38,16 +38,14 @@ export function DeletedParticleView({
|
||||
}, [paused, onEnded, particle.id]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center px-8">
|
||||
<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">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<Trash2 className="text-muted-foreground size-6" />
|
||||
<p className="text-foreground text-base font-medium">
|
||||
<Trash2 className="text-white/40 size-6" />
|
||||
<p className="text-white/70 text-base font-medium">
|
||||
This particle was deleted
|
||||
</p>
|
||||
{deleter && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
by {deleter.displayName}
|
||||
</p>
|
||||
<p className="text-white/40 text-xs">by {deleter.displayName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,17 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { FileIcon, HelpCircleIcon, BookOpenIcon } from 'lucide-react';
|
||||
import {
|
||||
FileIcon,
|
||||
HelpCircleIcon,
|
||||
ScrollTextIcon,
|
||||
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' },
|
||||
};
|
||||
@@ -31,19 +37,19 @@ export function FallbackParticleView({
|
||||
);
|
||||
const meta = TYPE_META[particle.type] ?? {
|
||||
icon: HelpCircleIcon,
|
||||
label: particle.type === 'unknown' ? particle.raw_type : particle.type,
|
||||
label: 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,12 +1,21 @@
|
||||
import { Particle } from '@/api/types';
|
||||
import { type ParticlePath } from '@/lib/particle-path';
|
||||
import { ContainerView } from '@/features/particles/container-view';
|
||||
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
||||
import { ComposeOverlay } from '@/features/compose/compose-overlay';
|
||||
|
||||
interface FolderViewProps {
|
||||
folderParticle: Particle & { type: 'folder' };
|
||||
folderParticle: Particle;
|
||||
path: ParticlePath;
|
||||
}
|
||||
|
||||
export function FolderView({ path, folderParticle }: FolderViewProps) {
|
||||
return <ContainerView path={path} folderParticle={folderParticle} />;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ export const MediaParticleView = forwardRef<
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<div className="bg-scrim/90 relative flex h-full w-full items-center justify-center">
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
||||
<audio
|
||||
ref={(el) => {
|
||||
audioRef.current = el;
|
||||
@@ -186,7 +186,7 @@ export const MediaParticleView = forwardRef<
|
||||
playsInline
|
||||
onEnded={onEnded}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
className="bg-black h-full w-full object-contain"
|
||||
className="h-full w-full object-contain bg-black"
|
||||
/>
|
||||
|
||||
{transcript && (
|
||||
|
||||
@@ -63,7 +63,7 @@ function ImageAttachment({
|
||||
);
|
||||
|
||||
if (isLoading || !url) {
|
||||
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg" />;
|
||||
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
|
||||
}
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
@@ -78,7 +78,7 @@ function ImageAttachment({
|
||||
e.stopPropagation();
|
||||
onPreview();
|
||||
}}
|
||||
className="bg-muted group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg"
|
||||
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
@@ -89,7 +89,7 @@ function ImageAttachment({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleDownload}
|
||||
className="bg-scrim/60 absolute bottom-1 right-1 rounded-full p-1 text-white/80 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||||
className="absolute bottom-1 right-1 rounded-full bg-black/60 p-1 text-white/70 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</span>
|
||||
@@ -123,14 +123,14 @@ function FileAttachment({
|
||||
<div
|
||||
role="button"
|
||||
onClick={handleOpen}
|
||||
className="bg-muted hover:bg-accent flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg px-3 py-2 transition-colors"
|
||||
className="flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg bg-white/10 px-3 py-2 transition-colors hover:bg-white/15"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="text-foreground max-w-[10rem] truncate text-xs font-medium">
|
||||
<FileIcon className="size-4 shrink-0 text-white/60" />
|
||||
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
|
||||
{particle.properties.filename}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
<span className="text-[10px] text-white/40">
|
||||
{formatFileSize(particle.properties.size_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ function FileAttachment({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
@@ -147,7 +147,7 @@ function FileAttachment({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download data-icon="inline-start" />
|
||||
@@ -177,7 +177,7 @@ function CompactAttachmentItem({
|
||||
e.stopPropagation();
|
||||
openParticle(particle, index, url, onPreview);
|
||||
}}
|
||||
className="bg-muted hover:bg-accent flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors"
|
||||
className="flex w-full items-center gap-2 rounded-md bg-white/10 px-2.5 py-1.5 text-left transition-colors hover:bg-white/15"
|
||||
>
|
||||
{isImage ? (
|
||||
url ? (
|
||||
@@ -187,15 +187,15 @@ function CompactAttachmentItem({
|
||||
className="size-5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<ImageIcon className="size-4 shrink-0 text-white/50" />
|
||||
)
|
||||
) : (
|
||||
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<FileIcon className="size-4 shrink-0 text-white/50" />
|
||||
)}
|
||||
<span className="text-foreground min-w-0 truncate text-xs">
|
||||
<span className="min-w-0 truncate text-xs text-white/80">
|
||||
{particle.properties.filename}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||
<span className="shrink-0 text-[10px] text-white/40">
|
||||
{formatFileSize(particle.properties.size_bytes)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
+117
-210
@@ -1,19 +1,32 @@
|
||||
import {
|
||||
Fragment,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
memo,
|
||||
createElement,
|
||||
} from 'react';
|
||||
import { Headphones, Radio, FolderIcon } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Radio,
|
||||
MessageSquare,
|
||||
Video,
|
||||
Mic,
|
||||
Image,
|
||||
FileText,
|
||||
CircleCheck,
|
||||
StickyNote,
|
||||
Headphones,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} 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 { HumanAvatar } from '@/components/human-avatar';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Small } from '@/components/ui/typography';
|
||||
@@ -23,17 +36,12 @@ 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,
|
||||
@@ -63,16 +71,65 @@ 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,
|
||||
onOpen,
|
||||
onNavigate,
|
||||
isSelected,
|
||||
shortcutKey,
|
||||
}: {
|
||||
particle: StreamParticle;
|
||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
||||
networkId: string;
|
||||
onOpen: (particleId: string) => void;
|
||||
onNavigate: (streamId: string) => void;
|
||||
isSelected?: boolean;
|
||||
shortcutKey?: number;
|
||||
}) {
|
||||
@@ -93,7 +150,7 @@ const StreamRow = memo(function StreamRow({
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||
|
||||
const avatar = useMemo(() => {
|
||||
const initials = useMemo(() => {
|
||||
if (isDM) {
|
||||
const otherEntry = particle.visible_to.find(
|
||||
(v) => v !== `human:${userId}`,
|
||||
@@ -101,12 +158,7 @@ const StreamRow = memo(function StreamRow({
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace('human:', '');
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) {
|
||||
return {
|
||||
initials: getInitials(otherHuman.email),
|
||||
avatarObjectId: otherHuman.avatar_object_id ?? null,
|
||||
};
|
||||
}
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,18 +166,10 @@ const StreamRow = memo(function StreamRow({
|
||||
const creator = network?.humans?.find(
|
||||
(h) => h.id === latestChild.created_by_human_id,
|
||||
);
|
||||
if (creator) {
|
||||
return {
|
||||
initials: getInitials(creator.email),
|
||||
avatarObjectId: creator.avatar_object_id ?? null,
|
||||
};
|
||||
}
|
||||
if (creator) return getInitials(creator.email);
|
||||
}
|
||||
|
||||
return {
|
||||
initials: particle.properties.name.slice(0, 2).toUpperCase(),
|
||||
avatarObjectId: null,
|
||||
};
|
||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||
}, [
|
||||
isDM,
|
||||
particle.visible_to,
|
||||
@@ -180,9 +224,9 @@ const StreamRow = memo(function StreamRow({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(particle.id)}
|
||||
onClick={() => onNavigate(particle.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onOpen(particle.id);
|
||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(particle.id);
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
|
||||
@@ -191,19 +235,18 @@ const StreamRow = memo(function StreamRow({
|
||||
)}
|
||||
>
|
||||
{shortcutKey && (
|
||||
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
||||
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
|
||||
{shortcutKey}
|
||||
</kbd>
|
||||
)}
|
||||
{videoThumbObjectId ? (
|
||||
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
||||
) : (
|
||||
<HumanAvatar
|
||||
className={cn(isUnseen && 'ring-2 ring-primary')}
|
||||
avatarObjectId={avatar.avatarObjectId}
|
||||
initials={avatar.initials}
|
||||
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||
/>
|
||||
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -220,8 +263,8 @@ const StreamRow = memo(function StreamRow({
|
||||
<div className="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-600 dark:text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-600 dark:text-red-400">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">
|
||||
{huddleCount}
|
||||
</span>
|
||||
</span>
|
||||
@@ -265,160 +308,34 @@ const StreamRow = memo(function StreamRow({
|
||||
);
|
||||
});
|
||||
|
||||
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="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
||||
{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="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
||||
{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[];
|
||||
interface ParticleListViewProps {
|
||||
streams: StreamParticle[];
|
||||
networkId: string;
|
||||
isLoading: boolean;
|
||||
/** Open (navigate into / select) a particle by id. */
|
||||
onOpen: (particleId: string) => void;
|
||||
selectedIndex?: number | null;
|
||||
/** Render 1–9 shortcut badges next to the first nine rows. */
|
||||
showShortcuts?: boolean;
|
||||
emptyMessage?: string;
|
||||
/** When true, render a footer that invokes onLoadMore. */
|
||||
canLoadMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsable list of a container's children, any particle type. Used by the
|
||||
* network root and folder views.
|
||||
* List of stream particles for a container (network root, folder, etc.).
|
||||
*/
|
||||
export function ParticleChildrenList({
|
||||
items,
|
||||
export function ParticleListView({
|
||||
streams,
|
||||
networkId,
|
||||
isLoading,
|
||||
onOpen,
|
||||
selectedIndex,
|
||||
showShortcuts = true,
|
||||
emptyMessage = 'Nothing here yet. Create something using the keyboard shortcuts below.',
|
||||
canLoadMore,
|
||||
onLoadMore,
|
||||
}: ParticleChildrenListProps) {
|
||||
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
}: ParticleListViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const navigateToStream = useCallback(
|
||||
(streamId: string) => navigate(`/${networkId}/${streamId}`),
|
||||
[navigate, networkId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -434,52 +351,42 @@ export function ParticleChildrenList({
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
if (streams.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">{emptyMessage}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No streams here. Start a conversation using the keyboard shortcuts
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item, index) => {
|
||||
const rowProps = {
|
||||
networkId,
|
||||
onOpen,
|
||||
isSelected: index === selectedIndex,
|
||||
shortcutKey: showShortcuts && index < 9 ? index + 1 : undefined,
|
||||
};
|
||||
const row = (
|
||||
{streams.map((stream, index) => (
|
||||
<StreamContextMenu
|
||||
key={stream.id}
|
||||
particle={stream}
|
||||
networkId={networkId}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
rowRefs.current[index] = el;
|
||||
}}
|
||||
>
|
||||
{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" />}
|
||||
<StreamRow
|
||||
particle={stream}
|
||||
networkId={networkId}
|
||||
onNavigate={navigateToStream}
|
||||
isSelected={index === selectedIndex}
|
||||
shortcutKey={index < 9 ? index + 1 : undefined}
|
||||
/>
|
||||
{index < streams.length - 1 && <Separator className="px-4" />}
|
||||
</div>
|
||||
);
|
||||
return item.type === 'stream' ? (
|
||||
<StreamContextMenu
|
||||
key={item.id}
|
||||
particle={item}
|
||||
networkId={networkId}
|
||||
>
|
||||
{row}
|
||||
</StreamContextMenu>
|
||||
) : (
|
||||
<Fragment key={item.id}>{row}</Fragment>
|
||||
);
|
||||
})}
|
||||
</StreamContextMenu>
|
||||
))}
|
||||
{canLoadMore && onLoadMore && (
|
||||
<div className="flex justify-center p-3">
|
||||
<Button variant="ghost" size="sm" onClick={onLoadMore}>
|
||||
@@ -5,12 +5,11 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Video,
|
||||
Mic,
|
||||
CircleCheck,
|
||||
ScrollText,
|
||||
BookOpen,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ParticlePreview({ particle }: { particle: Particle }) {
|
||||
switch (particle.type) {
|
||||
@@ -18,8 +17,8 @@ export function ParticlePreview({ particle }: { particle: Particle }) {
|
||||
return <TextPreview particle={particle} />;
|
||||
case 'media':
|
||||
return <MediaPreview particle={particle} />;
|
||||
case 'task':
|
||||
return <TaskPreview particle={particle} />;
|
||||
case 'quest':
|
||||
return <QuestPreview particle={particle} />;
|
||||
case 'paper':
|
||||
return <PaperPreview particle={particle} />;
|
||||
case 'file':
|
||||
@@ -69,9 +68,9 @@ function MediaPreview({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<Mic className="h-8 w-8" />
|
||||
<span className="font-mono text-xs">{durationLabel}</span>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
|
||||
<Mic className="h-8 w-8 text-white/60" />
|
||||
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,8 +102,8 @@ function VideoThumbnail({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-muted flex h-full w-full items-center justify-center">
|
||||
<Video className="text-muted-foreground h-8 w-8" />
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/80">
|
||||
<Video className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +113,7 @@ function VideoThumbnail({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted relative h-full w-full">
|
||||
<div className="relative h-full w-full bg-black">
|
||||
<video
|
||||
src={`${url}#t=2`}
|
||||
preload="metadata"
|
||||
@@ -122,37 +121,28 @@ function VideoThumbnail({
|
||||
playsInline
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="bg-scrim/70 absolute bottom-1.5 right-1.5 rounded px-1.5 py-0.5 font-mono text-[10px] text-white">
|
||||
<span className="absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] text-white/80">
|
||||
{duration}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskPreview({
|
||||
function QuestPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'task' }>;
|
||||
particle: Extract<Particle, { type: 'quest' }>;
|
||||
}) {
|
||||
const { title, done } = particle.properties;
|
||||
const { title, status } = particle.properties;
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,12 @@ 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, type ParticlePath } from '@/lib/particle-path';
|
||||
import { 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/*.
|
||||
@@ -32,11 +25,9 @@ export default function ParticleViewResolver() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,106 +35,25 @@ 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 (
|
||||
<Layout>
|
||||
<InaccessibleParticle />
|
||||
</Layout>
|
||||
);
|
||||
return <InaccessibleParticle />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Layout>
|
||||
<FolderView folderParticle={particle} path={path} />
|
||||
</Layout>
|
||||
);
|
||||
return <FolderView folderParticle={particle} path={path} />;
|
||||
default:
|
||||
return (
|
||||
<Layout>
|
||||
<LeafParticleView
|
||||
particle={particle}
|
||||
containerPath={particlePath(networkId, segments.slice(0, -1))}
|
||||
networkId={networkId}
|
||||
/>
|
||||
</Layout>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{particle.type} particle: {particle.id}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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="bg-background min-h-0 flex-1 [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InaccessibleParticle() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HumanAvatar } from '@/components/human-avatar';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -77,10 +77,10 @@ export function PlaybackPageIndicator({
|
||||
className="group relative block h-3 w-full"
|
||||
>
|
||||
{/* Dim track */}
|
||||
<div className="bg-muted-foreground/30 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
|
||||
{/* Fill */}
|
||||
<div
|
||||
className="bg-foreground absolute bottom-0 left-0 h-[3px] transition-all group-hover:h-1.5"
|
||||
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
|
||||
style={{
|
||||
width:
|
||||
i < current
|
||||
@@ -107,7 +107,7 @@ export function PlaybackPageIndicator({
|
||||
)}
|
||||
</div>
|
||||
{paginated && showTracks && current >= 0 && (
|
||||
<div className="text-muted-foreground pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide">
|
||||
<div className="pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide text-white/40">
|
||||
{current + 1} / {total}
|
||||
</div>
|
||||
)}
|
||||
@@ -141,7 +141,7 @@ function GhostStub({
|
||||
className="group relative block h-3 w-2 shrink-0"
|
||||
aria-label="Jump to adjacent page"
|
||||
>
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-muted-foreground/40 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/15 transition-all group-hover:h-1.5 group-hover:bg-white/40" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -161,16 +161,18 @@ function SegmentPresenceAvatars({
|
||||
{visible.map((human) => (
|
||||
<Tooltip key={human.humanId}>
|
||||
<TooltipTrigger asChild>
|
||||
<HumanAvatar
|
||||
<Avatar
|
||||
size="xs"
|
||||
className={
|
||||
onlineHumanIds?.has(human.humanId)
|
||||
? 'ring-2 ring-green-500'
|
||||
: 'ring-1 ring-background'
|
||||
: 'ring-1 ring-black/50'
|
||||
}
|
||||
avatarObjectId={human.avatarObjectId}
|
||||
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||
/>
|
||||
>
|
||||
<AvatarFallback>
|
||||
{human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{human.email}
|
||||
@@ -178,9 +180,7 @@ function SegmentPresenceAvatars({
|
||||
</Tooltip>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="text-muted-foreground pl-1 text-[10px]">
|
||||
+{overflow}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { HumanAvatar } from '@/components/human-avatar';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
@@ -77,12 +77,12 @@ export function ReactionBar({
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
|
||||
isMine
|
||||
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
||||
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
||||
? 'bg-white/20 ring-1 ring-white/40'
|
||||
: 'bg-black/40 hover:bg-black/50',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{emoji}</span>
|
||||
<span className="text-muted-foreground">{reactors.length}</span>
|
||||
<span className="text-white/80">{reactors.length}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
@@ -109,20 +109,18 @@ export function ReactionBar({
|
||||
className={cn(
|
||||
'flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors',
|
||||
isMine
|
||||
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
||||
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
||||
? 'bg-white/20 ring-1 ring-white/40'
|
||||
: 'bg-black/40 hover:bg-black/50',
|
||||
)}
|
||||
>
|
||||
<HumanAvatar
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
avatarObjectId={firstReactor.avatarObjectId}
|
||||
initials={firstReactor.initials}
|
||||
fallbackClassName="bg-muted text-muted-foreground text-[9px] font-medium"
|
||||
/>
|
||||
<span className="truncate text-foreground">{text}</span>
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white">
|
||||
{firstReactor.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-white/90">{text}</span>
|
||||
{reactors.length > 1 && (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
<span className="shrink-0 text-white/60">
|
||||
{reactors.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -154,7 +152,7 @@ export function ReactionBar({
|
||||
|
||||
{/* Picker / actions */}
|
||||
{expanded ? (
|
||||
<div className="bg-card/80 border-border flex flex-col items-center gap-0.5 rounded-full border px-0.5 py-1.5 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-0.5 rounded-full bg-black/40 px-0.5 py-1.5 backdrop-blur-sm">
|
||||
{REACTION_EMOJIS.map((emoji) => {
|
||||
if (activeEmojis.includes(emoji)) return null;
|
||||
return (
|
||||
@@ -164,7 +162,7 @@ export function ReactionBar({
|
||||
e.stopPropagation();
|
||||
handleToggle(emoji);
|
||||
}}
|
||||
className="hover:bg-accent rounded-full px-0.5 py-1 text-sm transition-colors"
|
||||
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
@@ -175,9 +173,9 @@ export function ReactionBar({
|
||||
e.stopPropagation();
|
||||
setExpanded(false);
|
||||
}}
|
||||
className="hover:bg-accent flex size-5 items-center justify-center rounded-full transition-colors"
|
||||
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
|
||||
>
|
||||
<X className="text-muted-foreground size-3" />
|
||||
<X className="size-3 text-white/60" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -189,14 +187,14 @@ export function ReactionBar({
|
||||
e.stopPropagation();
|
||||
onOpenTextReaction();
|
||||
}}
|
||||
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
||||
>
|
||||
<Type className="text-muted-foreground size-3" />
|
||||
<Type className="size-3 text-white/60" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Quick reply{' '}
|
||||
<kbd className="bg-muted ml-1 rounded px-1 font-mono text-[10px]">
|
||||
<kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">
|
||||
R
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
@@ -206,9 +204,9 @@ export function ReactionBar({
|
||||
e.stopPropagation();
|
||||
setExpanded(true);
|
||||
}}
|
||||
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
||||
>
|
||||
<Plus className="text-muted-foreground size-3" />
|
||||
<Plus className="size-3 text-white/60" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
interface RenameStreamOverlayProps {
|
||||
streamPath: ParticlePath;
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RenameStreamOverlay({
|
||||
streamPath,
|
||||
networkId,
|
||||
streamParticle,
|
||||
onClose,
|
||||
}: RenameStreamOverlayProps) {
|
||||
@@ -32,13 +32,15 @@ export function RenameStreamOverlay({
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const docPath = toFirestoreDocPath(streamPath);
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id]),
|
||||
);
|
||||
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [canSave, streamPath, onClose, trimmed]);
|
||||
}, [canSave, networkId, onClose, streamParticle.id, trimmed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -56,17 +58,17 @@ export function RenameStreamOverlay({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<div
|
||||
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
|
||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">Rename stream</h2>
|
||||
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
|
||||
<KeyHint
|
||||
keys="Esc"
|
||||
onClick={onClose}
|
||||
title="Close (or press Esc)"
|
||||
className="text-muted-foreground text-xs"
|
||||
className="text-xs text-white/30"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
@@ -85,6 +87,7 @@ export function RenameStreamOverlay({
|
||||
}
|
||||
}}
|
||||
placeholder="Stream name"
|
||||
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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,
|
||||
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, 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="bg-card/70 text-muted-foreground border-border rounded-full border px-2 text-xs 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="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { Headphones } from 'lucide-react';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import type { Particle, StreamProperties } from '@/api/types';
|
||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
||||
import { ParticlePreview } from '@/features/particles/particle-preview';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { Small } from '@/components/ui/typography';
|
||||
|
||||
interface StreamCardProps {
|
||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
||||
networkId: string;
|
||||
onClick: () => void;
|
||||
isSelected?: boolean;
|
||||
shortcutKey?: number;
|
||||
}
|
||||
|
||||
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
|
||||
function StreamCard(
|
||||
{ particle, networkId, onClick, isSelected, shortcutKey },
|
||||
ref,
|
||||
) {
|
||||
const streamPath = particlePath(networkId, [particle.id]);
|
||||
const { latestChild } = useLiveLatestChild(streamPath);
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? '';
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
||||
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants &&
|
||||
particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||
|
||||
const initials = useMemo(() => {
|
||||
if (isDM) {
|
||||
const otherEntry = particle.visible_to.find(
|
||||
(v) => v !== `human:${userId}`,
|
||||
);
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace('human:', '');
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestChild) {
|
||||
const creator = network?.humans?.find(
|
||||
(h) => h.id === latestChild.created_by_human_id,
|
||||
);
|
||||
if (creator) return getInitials(creator.email);
|
||||
}
|
||||
|
||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||
}, [
|
||||
isDM,
|
||||
particle.visible_to,
|
||||
particle.properties.name,
|
||||
userId,
|
||||
latestChild,
|
||||
network,
|
||||
]);
|
||||
|
||||
const isUnseen = useMemo(() => {
|
||||
if (!latestChild) return false;
|
||||
const latestChildTimestamp = latestChild.created_at.getTime();
|
||||
const userPlaybackPosition =
|
||||
particle.playback_markers?.[userId]?.getTime() ?? 0;
|
||||
return latestChildTimestamp > userPlaybackPosition;
|
||||
}, [latestChild, particle.playback_markers, userId]);
|
||||
|
||||
// For media particles with a transcript, show it as an overlay on the preview
|
||||
const transcript =
|
||||
latestChild?.type === 'media'
|
||||
? latestChild.properties.transcript?.transcript
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onClick();
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20',
|
||||
isUnseen && 'ring-2 ring-primary',
|
||||
isSelected && 'ring-2 ring-ring',
|
||||
hasActiveHuddle && 'ring-2 ring-red-500/70',
|
||||
)}
|
||||
>
|
||||
{/* Preview area */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
||||
{hasActiveHuddle && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
||||
)}
|
||||
{shortcutKey && (
|
||||
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
||||
{shortcutKey}
|
||||
</kbd>
|
||||
)}
|
||||
{latestChild ? (
|
||||
<ParticlePreview particle={latestChild} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
No messages yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcript overlay for media with transcripts */}
|
||||
{transcript && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
|
||||
<p className="line-clamp-2 text-md leading-snug text-white/90">
|
||||
{transcript}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Avatar
|
||||
className={cn('size-6 shrink-0', isUnseen && 'ring-2 ring-primary')}
|
||||
>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Small
|
||||
className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnseen
|
||||
? 'font-semibold text-foreground'
|
||||
: 'font-medium text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{particle.properties.name}
|
||||
</Small>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">
|
||||
{huddleCount}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{latestChild && (
|
||||
<Small
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
isUnseen ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<RelativeTimestamp date={latestChild.created_at} />
|
||||
</Small>
|
||||
)}
|
||||
{isUnseen && (
|
||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 { isStreamOpen, type Particle } from '@/api/types';
|
||||
import type { StreamParticle } from '@/hooks/use-stream-particles';
|
||||
|
||||
interface StreamContextMenuProps {
|
||||
particle: Extract<Particle, { type: 'stream' }>;
|
||||
particle: StreamParticle;
|
||||
networkId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function StreamContextMenu({
|
||||
networkId,
|
||||
children,
|
||||
}: StreamContextMenuProps) {
|
||||
const isOpen = isStreamOpen(particle);
|
||||
const isOpen = particle.status === 'open';
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
|
||||
|
||||
const toggleStatus = async () => {
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CircleCheck, FileText, Image, List, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse-mode panel beside the stream: a chat-like timeline of every
|
||||
* particle. Selecting a message plays it in the immersive stream view;
|
||||
* nothing auto-advances.
|
||||
*/
|
||||
export function StreamListSidebar({
|
||||
items,
|
||||
networkId,
|
||||
currentIndex,
|
||||
onSelect,
|
||||
onToggle,
|
||||
}: StreamListSidebarProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIndex >= 0) {
|
||||
rowRefs.current[currentIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [currentIndex]);
|
||||
|
||||
return (
|
||||
<aside className="bg-sidebar text-sidebar-foreground border-border flex w-60 shrink-0 flex-col overflow-hidden border-l">
|
||||
<div className="border-border flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||
<List className="text-muted-foreground size-3.5" />
|
||||
<span className="mr-auto truncate text-sm font-medium">
|
||||
{items.length} messages
|
||||
</span>
|
||||
<KeyHint
|
||||
keys="L"
|
||||
onClick={onToggle}
|
||||
title="Hide list (or press L)"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-0.5 px-2 py-2">
|
||||
{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="text-muted-foreground px-2 py-8 text-center text-sm">
|
||||
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-accent' : 'hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
className="mt-0.5 shrink-0"
|
||||
initials={sender.initials}
|
||||
avatarObjectId={sender.avatarObjectId}
|
||||
fallbackClassName="bg-muted text-muted-foreground 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">
|
||||
{sender.displayName}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||
<RelativeTimestamp date={particle.created_at} />
|
||||
</span>
|
||||
</div>
|
||||
<ChatRowContent particle={particle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatRowContent({ particle }: { particle: Particle }) {
|
||||
if (isParticleDeleted(particle)) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
This particle was deleted
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
switch (particle.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<p className="text-muted-foreground line-clamp-2 whitespace-pre-line text-xs leading-relaxed [overflow-wrap:anywhere]">
|
||||
{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="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Icon className="text-muted-foreground size-3.5 shrink-0" />
|
||||
{label}
|
||||
{duration}
|
||||
</span>
|
||||
{transcript && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{transcript}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'file':
|
||||
return (
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<FileText className="text-muted-foreground size-3.5 shrink-0" />
|
||||
<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="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'size-3.5 shrink-0',
|
||||
done
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate',
|
||||
done && 'text-muted-foreground line-through',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
{checklist.length > 0 && (
|
||||
<span className="text-muted-foreground pl-5 text-[10px]">
|
||||
{doneCount} / {checklist.length} subtasks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'paper':
|
||||
return (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{particle.properties.title}
|
||||
</p>
|
||||
);
|
||||
default:
|
||||
return <p className="text-muted-foreground text-xs">{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 { HumanAvatar } from '@/components/human-avatar';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/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 { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
@@ -19,7 +19,6 @@ import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
interface StreamMembersOverlayProps {
|
||||
networkId: string;
|
||||
streamPath: ParticlePath;
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
isCreator: boolean;
|
||||
onClose: () => void;
|
||||
@@ -27,7 +26,6 @@ interface StreamMembersOverlayProps {
|
||||
|
||||
export function StreamMembersOverlay({
|
||||
networkId,
|
||||
streamPath,
|
||||
streamParticle,
|
||||
isCreator,
|
||||
onClose,
|
||||
@@ -42,7 +40,10 @@ export function StreamMembersOverlay({
|
||||
[streamParticle.visible_to, networkId],
|
||||
);
|
||||
|
||||
const docPath = useMemo(() => toFirestoreDocPath(streamPath), [streamPath]);
|
||||
const docPath = useMemo(
|
||||
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
||||
[networkId, streamParticle.id],
|
||||
);
|
||||
|
||||
const memberIds =
|
||||
visibility.mode === 'network'
|
||||
@@ -97,18 +98,18 @@ export function StreamMembersOverlay({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<div
|
||||
className="bg-scrim/60 absolute inset-0 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
|
||||
<div className="absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">Members</h2>
|
||||
<h2 className="text-sm font-semibold text-white/70">Members</h2>
|
||||
<KeyHint
|
||||
keys="Esc"
|
||||
onClick={onClose}
|
||||
title="Close (or press Esc)"
|
||||
className="text-muted-foreground text-xs"
|
||||
className="text-xs text-white/30"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
@@ -116,11 +117,11 @@ export function StreamMembersOverlay({
|
||||
|
||||
{/* Visibility */}
|
||||
<section className="mb-4">
|
||||
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
Visibility
|
||||
</h3>
|
||||
{isCreator ? (
|
||||
<div className="bg-muted grid grid-cols-2 gap-1 rounded-lg p-1">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
|
||||
<VisibilityPill
|
||||
active={visibility.mode === 'network'}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
@@ -135,15 +136,15 @@ export function StreamMembersOverlay({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-foreground flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-white/70">
|
||||
{visibility.mode === 'network' ? (
|
||||
<>
|
||||
<Globe className="text-muted-foreground size-3.5" />
|
||||
<Globe className="size-3.5 text-white/40" />
|
||||
<span>Everyone in {network?.name ?? 'network'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="text-muted-foreground size-3.5" />
|
||||
<Lock className="size-3.5 text-white/40" />
|
||||
<span>{memberIds.length} specific people</span>
|
||||
</>
|
||||
)}
|
||||
@@ -153,11 +154,9 @@ export function StreamMembersOverlay({
|
||||
|
||||
{/* Member list */}
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{memberIds.length}
|
||||
</span>
|
||||
<span className="ml-1 text-white/20">{memberIds.length}</span>
|
||||
</h3>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<ul className="flex flex-col gap-0.5 pr-2">
|
||||
@@ -169,24 +168,23 @@ export function StreamMembersOverlay({
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className="text-foreground group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm"
|
||||
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
|
||||
>
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
avatarObjectId={display.avatarObjectId}
|
||||
initials={display.initials}
|
||||
fallbackClassName="text-[10px]"
|
||||
/>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{display.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 truncate',
|
||||
!display.exists && 'text-muted-foreground italic',
|
||||
!display.exists && 'italic text-white/40',
|
||||
)}
|
||||
>
|
||||
{display.displayName}
|
||||
</span>
|
||||
{isCreatorRow && (
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
|
||||
<span className="text-[10px] uppercase tracking-wider text-white/30">
|
||||
Creator
|
||||
</span>
|
||||
)}
|
||||
@@ -194,7 +192,7 @@ export function StreamMembersOverlay({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember(id)}
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100"
|
||||
aria-label={`Remove ${display.displayName}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
@@ -211,8 +209,8 @@ export function StreamMembersOverlay({
|
||||
{isCreator &&
|
||||
visibility.mode === 'custom' &&
|
||||
availableToAdd.length > 0 && (
|
||||
<section className="border-border mt-4 border-t pt-4">
|
||||
<h3 className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest">
|
||||
<section className="mt-4 border-t border-white/5 pt-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
<UserPlus className="size-3" />
|
||||
Add people
|
||||
</h3>
|
||||
@@ -224,19 +222,18 @@ export function StreamMembersOverlay({
|
||||
type="button"
|
||||
onClick={() => addMember(human.id)}
|
||||
className={cn(
|
||||
'text-foreground hover:bg-accent flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm transition-colors',
|
||||
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
avatarObjectId={human.avatar_object_id}
|
||||
initials={getInitials(human.email)}
|
||||
fallbackClassName="text-[10px]"
|
||||
/>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getInitials(human.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">
|
||||
{human.email_prefix}
|
||||
</span>
|
||||
<UserPlus className="text-muted-foreground size-3.5" />
|
||||
<UserPlus className="size-3.5 text-white/30" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -248,7 +245,7 @@ export function StreamMembersOverlay({
|
||||
{isCreator &&
|
||||
visibility.mode === 'custom' &&
|
||||
availableToAdd.length === 0 && (
|
||||
<p className="text-muted-foreground mt-4 text-center text-xs">
|
||||
<p className="mt-4 text-center text-xs text-white/30">
|
||||
<Users className="mr-1 inline size-3" />
|
||||
Everyone in the network is already a member
|
||||
</p>
|
||||
@@ -277,8 +274,8 @@ function VisibilityPill({
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
|
||||
active
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
? 'bg-white/10 text-white/90'
|
||||
: 'text-white/50 hover:text-white/80',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { isParticleDeleted, isStreamOpen, type Particle } from '@/api/types';
|
||||
import { AvatarGroup } from '@/components/ui/avatar';
|
||||
import { HumanAvatar } from '@/components/human-avatar';
|
||||
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -18,21 +19,20 @@ 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,20 +47,32 @@ 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,
|
||||
streamPath,
|
||||
}: TopBarProps) {
|
||||
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
const navigate = useNavigate();
|
||||
const network = useNetwork(networkId);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
||||
@@ -92,7 +104,7 @@ export function TopBar({
|
||||
<div className="drag-region flex flex-row px-2 gap-1 items-center">
|
||||
<WindowControls />
|
||||
|
||||
<Breadcrumb className="no-drag bg-card/70 border-border mx-auto rounded-full border px-3 py-1 backdrop-blur-sm">
|
||||
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
|
||||
<BreadcrumbList>
|
||||
{streamParticle && (
|
||||
<>
|
||||
@@ -135,26 +147,23 @@ export function TopBar({
|
||||
return (
|
||||
<Tooltip key={humanId}>
|
||||
<TooltipTrigger asChild>
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
avatarObjectId={display.avatarObjectId}
|
||||
initials={display.initials}
|
||||
fallbackClassName="bg-red-500/30 text-[8px] text-red-700 dark:text-red-200"
|
||||
/>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
||||
{display.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{display.email}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
<span className="text-xs font-medium text-red-600 dark:text-red-200">
|
||||
Join
|
||||
</span>
|
||||
<span className="text-xs font-medium text-red-200">Join</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isStreamOpen(streamParticle) && (
|
||||
<span className="no-drag text-muted-foreground bg-muted border-border flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs backdrop-blur-sm">
|
||||
{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">
|
||||
<CircleCheckBig className="size-3" />
|
||||
Closed
|
||||
</span>
|
||||
@@ -177,22 +186,18 @@ export function TopBar({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isCreator && (
|
||||
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename stream
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
const docPath = toFirestoreDocPath(streamPath);
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id]),
|
||||
);
|
||||
await updateStreamStatus(
|
||||
docPath,
|
||||
isStreamOpen(streamParticle) ? 'closed' : 'open',
|
||||
streamParticle.status === 'open' ? 'closed' : 'open',
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isStreamOpen(streamParticle) ? (
|
||||
{streamParticle.status === 'open' ? (
|
||||
<>
|
||||
<CircleCheckBig className="size-4" />
|
||||
Close stream
|
||||
@@ -204,6 +209,12 @@ export function TopBar({
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{isCreator && (
|
||||
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename stream
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDeleteParticle && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
@@ -213,12 +224,16 @@ export function TopBar({
|
||||
Delete particle
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => navigate('/settings')}>
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{renameOpen && isCreator && (
|
||||
<RenameStreamOverlay
|
||||
streamPath={streamPath}
|
||||
networkId={networkId}
|
||||
streamParticle={streamParticle}
|
||||
onClose={() => setRenameOpen(false)}
|
||||
/>
|
||||
@@ -226,7 +241,8 @@ export function TopBar({
|
||||
|
||||
{deleteOpen && canDeleteParticle && particle && userId && (
|
||||
<DeleteParticleOverlay
|
||||
streamPath={streamPath}
|
||||
networkId={networkId}
|
||||
streamId={streamParticle.id}
|
||||
particle={particle}
|
||||
userId={userId}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
@@ -236,7 +252,6 @@ export function TopBar({
|
||||
{membersOpen && (
|
||||
<StreamMembersOverlay
|
||||
networkId={networkId}
|
||||
streamPath={streamPath}
|
||||
streamParticle={streamParticle}
|
||||
isCreator={isCreator}
|
||||
onClose={() => setMembersOpen(false)}
|
||||
@@ -282,33 +297,31 @@ function MembersIndicator({
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="no-drag bg-card/70 text-muted-foreground border-border hover:bg-accent flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs backdrop-blur-sm transition-colors"
|
||||
className="no-drag flex items-center gap-1.5 rounded-full bg-white/5 px-2 py-1 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-white/10"
|
||||
>
|
||||
{isNetworkWide ? (
|
||||
<>
|
||||
<Globe className="text-muted-foreground size-3" />
|
||||
<Globe className="size-3 text-white/50" />
|
||||
<span>Everyone</span>
|
||||
</>
|
||||
) : isPrivate ? (
|
||||
<>
|
||||
<Lock className="text-muted-foreground size-3" />
|
||||
<Lock className="size-3 text-white/50" />
|
||||
<span>Private</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AvatarGroup>
|
||||
{shownMembers.map((human) => (
|
||||
<HumanAvatar
|
||||
key={human.id}
|
||||
size="sm"
|
||||
avatarObjectId={human.avatar_object_id}
|
||||
initials={resolveHumanDisplay(human.id, humans).initials}
|
||||
fallbackClassName="text-[8px]"
|
||||
/>
|
||||
<Avatar key={human.id} size="sm">
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{resolveHumanDisplay(human.id, humans).initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
{overflow > 0 && (
|
||||
<span className="text-muted-foreground">+{overflow}</span>
|
||||
<span className="text-white/50">+{overflow}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -342,12 +355,9 @@ function ParticleBreadcrumbContent({
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
className={isOnline ? 'ring-2 ring-green-500' : ''}
|
||||
avatarObjectId={display.avatarObjectId}
|
||||
initials={display.initials}
|
||||
/>
|
||||
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
|
||||
<AvatarFallback>{display.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -20,20 +19,17 @@ 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 {
|
||||
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 { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
import {
|
||||
KeybindingsOverlay,
|
||||
@@ -55,6 +51,7 @@ 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,
|
||||
@@ -66,29 +63,11 @@ 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' ||
|
||||
particle.type === 'task'
|
||||
)
|
||||
if (particle.type === 'media' || particle.type === 'text')
|
||||
return particle.reactions;
|
||||
return undefined;
|
||||
}
|
||||
@@ -155,7 +134,6 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
||||
bindings: [
|
||||
{ keys: ['←', '→', '↑', '↓'], description: 'Previous / next particle' },
|
||||
{ keys: ['Esc'], description: 'Back to network' },
|
||||
{ keys: ['L'], description: 'Toggle list view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -173,7 +151,6 @@ 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' },
|
||||
],
|
||||
@@ -212,8 +189,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
platform.autoplay.dismiss();
|
||||
});
|
||||
|
||||
const { mode, toggle: toggleViewMode } = useStreamViewMode();
|
||||
|
||||
const {
|
||||
children,
|
||||
currentParticle,
|
||||
@@ -223,9 +198,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
prev,
|
||||
goTo,
|
||||
goToParticle,
|
||||
} = useStreamPlayback(streamParticle, path, {
|
||||
autoAdvanceOnNew: mode === 'player',
|
||||
});
|
||||
} = useStreamPlayback(streamParticle, path);
|
||||
|
||||
usePrefetchAdjacentMedia(children, currentIndex);
|
||||
|
||||
@@ -285,7 +258,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
[handleToggleReaction],
|
||||
);
|
||||
|
||||
const { fastPlayback, spacePaused, resume } = usePlaybackKeys({ mediaRef });
|
||||
const { fastPlayback } = usePlaybackKeys({ mediaRef });
|
||||
|
||||
const handleExitNavigate = useCallback(() => {
|
||||
navigate(`/${networkId}`);
|
||||
@@ -298,7 +271,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
childrenLength: children.length,
|
||||
mediaRef,
|
||||
onExit: handleExitNavigate,
|
||||
onToggleViewMode: toggleViewMode,
|
||||
});
|
||||
|
||||
const handleOpenHuddle = useCallback(() => {
|
||||
@@ -329,7 +301,16 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
// Broadcast composing state to other viewers
|
||||
useEffect(() => {
|
||||
const mode = STEP_TO_COMPOSING_MODE[composeStep] ?? null;
|
||||
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;
|
||||
if (mode) {
|
||||
startComposing(mode);
|
||||
} else {
|
||||
@@ -350,12 +331,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
// Always show controls when compose is active or exit countdown is visible
|
||||
const controlsVisible = showControls || composeActive || status === 'ended';
|
||||
|
||||
// No auto-exit while browsing in list mode.
|
||||
const exitRemainingMs = useExitCountdown(
|
||||
status,
|
||||
paused || mode === 'list',
|
||||
handleExitNavigate,
|
||||
);
|
||||
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
|
||||
|
||||
// Reset progress when the particle changes.
|
||||
if (currentParticle?.id !== prevParticleId) {
|
||||
@@ -381,7 +357,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
<div className="bg-background flex h-full flex-col items-center justify-center gap-4">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No particles in this stream yet
|
||||
</p>
|
||||
@@ -395,15 +371,14 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
networkId={networkId}
|
||||
targetPath={path}
|
||||
onActiveChange={setComposeActive}
|
||||
disabled={streamParticle.status === 'closed'}
|
||||
onParticleCreated={handleParticleCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Render particle content inline
|
||||
function renderParticle(particle: Particle) {
|
||||
if (isParticleDeleted(particle)) {
|
||||
return (
|
||||
@@ -412,7 +387,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
particle={particle}
|
||||
networkId={networkId}
|
||||
paused={paused}
|
||||
onEnded={handleParticleEnded}
|
||||
onEnded={next}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -425,7 +400,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
particle={particle}
|
||||
streamPath={path}
|
||||
paused={paused}
|
||||
onEnded={handleParticleEnded}
|
||||
onEnded={next}
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
@@ -436,22 +411,10 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
particle={particle}
|
||||
streamPath={path}
|
||||
paused={paused}
|
||||
onEnded={handleParticleEnded}
|
||||
onEnded={next}
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
case 'task':
|
||||
return (
|
||||
<TaskParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
containerPath={path}
|
||||
paused={paused}
|
||||
onEnded={handleParticleEnded}
|
||||
onProgress={setProgress}
|
||||
immersive
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<FallbackParticleView particle={particle} networkId={networkId} />
|
||||
@@ -460,122 +423,233 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-screen overflow-hidden">
|
||||
{/* Stream chrome — immersive playback column */}
|
||||
<div
|
||||
className="bg-background relative flex min-w-0 flex-1 flex-col overflow-hidden [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
|
||||
onMouseMove={handleMouseActivity}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
{/* Top gradient safe zone */}
|
||||
<div className="from-background/80 pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b to-transparent" />
|
||||
<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" />
|
||||
|
||||
{/* TopBar — always visible */}
|
||||
<div className="z-10 absolute left-0 right-0 pt-2">
|
||||
<TopBar
|
||||
networkId={networkId}
|
||||
particle={currentParticle}
|
||||
streamParticle={streamParticle}
|
||||
streamPath={path}
|
||||
/>
|
||||
</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="bg-card/70 text-foreground border-border rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
|
||||
1.5x
|
||||
</div>
|
||||
)}
|
||||
{spacePaused && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resume}
|
||||
title="Resume (or press Space)"
|
||||
className="bg-card/70 text-muted-foreground border-border hover:bg-accent hover:text-foreground pointer-events-auto flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm transition-colors"
|
||||
>
|
||||
<Play className="size-3 fill-current" />
|
||||
Paused
|
||||
</button>
|
||||
)}
|
||||
</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)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composing indicator — left edge, always visible */}
|
||||
<ComposingIndicator
|
||||
users={composingUsers}
|
||||
networkHumans={network?.humans}
|
||||
/>
|
||||
|
||||
<ComposeOverlay
|
||||
{/* TopBar — always visible */}
|
||||
<div className="z-10 absolute left-0 right-0 pt-2">
|
||||
<TopBar
|
||||
networkId={networkId}
|
||||
targetPath={path}
|
||||
onActiveChange={setComposeActive}
|
||||
onStepChange={setComposeStep}
|
||||
onParticleCreated={handleParticleCreated}
|
||||
/>
|
||||
|
||||
{/* Bottom gradient safe zone for keyboard hints */}
|
||||
<div className="from-background/80 pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t to-transparent" />
|
||||
|
||||
{/* BottomBar — pinned visible while browsing, mouse-activity in player */}
|
||||
<BottomBar
|
||||
visible={mode === 'list' || 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"
|
||||
particle={currentParticle}
|
||||
streamParticle={streamParticle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Browse sidebar — a separate chat-like panel beside the stream */}
|
||||
{mode === 'list' && (
|
||||
<StreamListSidebar
|
||||
items={children}
|
||||
networkId={networkId}
|
||||
currentIndex={currentIndex}
|
||||
onSelect={goTo}
|
||||
onToggle={toggleViewMode}
|
||||
/>
|
||||
{/* 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)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composing indicator — left edge, always visible */}
|
||||
<ComposingIndicator
|
||||
users={composingUsers}
|
||||
networkHumans={network?.humans}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
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';
|
||||
import { isTypingTarget } from '@/lib/keyboard';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
|
||||
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
||||
|
||||
interface TaskParticleViewProps {
|
||||
particle: TaskParticle;
|
||||
containerPath: ParticlePath;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress?: (ratio: number) => void;
|
||||
/**
|
||||
* Enable focus mode while a field is focused: dim and blur the surrounding
|
||||
* stream, and trap stream keys (Escape leaves the editor, navigation keys
|
||||
* stay put). Off in the standalone leaf view, which has no stream chrome.
|
||||
*/
|
||||
immersive?: boolean;
|
||||
}
|
||||
|
||||
const DWELL_DURATION_S = 8;
|
||||
const UNASSIGNED = 'unassigned';
|
||||
|
||||
// Stream-navigation keys to swallow while editing so they can't pull focus to
|
||||
// another particle (only when focus isn't already in a text field).
|
||||
const NAV_KEYS = new Set([
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'l',
|
||||
'L',
|
||||
]);
|
||||
|
||||
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,
|
||||
immersive = false,
|
||||
}: 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}`);
|
||||
|
||||
const exitFocus = useCallback(() => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
}, []);
|
||||
|
||||
// While editing, the card acts like the app's other focus overlays: a
|
||||
// capture-phase listener consumes Escape (which blurs the field — flushing
|
||||
// the draft and resuming playback) and swallows stream-navigation keys, so
|
||||
// the stream's own handlers never see them. Self-contained, so no editing
|
||||
// state has to be threaded back up to the stream.
|
||||
useEffect(() => {
|
||||
if (!immersive || !editing) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
exitFocus();
|
||||
} else if (NAV_KEYS.has(e.key) && !isTypingTarget(e)) {
|
||||
// Arrows still move the caret inside text fields; only block them when
|
||||
// focus is on a non-text control (checkbox, assignee select).
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true });
|
||||
return () =>
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||
}, [immersive, editing, exitFocus]);
|
||||
|
||||
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 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
{immersive && editing && (
|
||||
<div
|
||||
className="animate-in fade-in-0 fixed inset-0 z-30 bg-scrim/50 backdrop-blur-md duration-200"
|
||||
onMouseDown={exitFocus}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'scrollbar-card bg-card text-card-foreground border-border 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 border p-[var(--message-card-padding)] transition-shadow',
|
||||
immersive &&
|
||||
editing &&
|
||||
'relative z-40 shadow-2xl shadow-black/50 ring-1 ring-border',
|
||||
)}
|
||||
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-muted-foreground/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(
|
||||
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-2xl font-semibold outline-none',
|
||||
done && 'text-muted-foreground line-through',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={notesField.value}
|
||||
onChange={(e) => notesField.onChange(e.target.value)}
|
||||
onFocus={notesField.onFocus}
|
||||
onBlur={notesField.onBlur}
|
||||
placeholder="Add notes…"
|
||||
className="text-foreground placeholder:text-muted-foreground min-h-16 resize-none border-none bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{checklist.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{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">
|
||||
{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>
|
||||
{immersive && editing && (
|
||||
<KeyHint
|
||||
keys="Esc"
|
||||
onClick={exitFocus}
|
||||
title="Finish editing (or press Esc)"
|
||||
className="fixed bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-40 -translate-x-1/2 text-xs text-white/60"
|
||||
>
|
||||
to finish
|
||||
</KeyHint>
|
||||
)}
|
||||
</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-muted-foreground/40 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(
|
||||
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none',
|
||||
item.done && 'text-muted-foreground line-through',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-muted-foreground hover:text-foreground opacity-0 transition-opacity 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="text-muted-foreground size-4" />
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
onBlur={submit}
|
||||
placeholder="Add subtask…"
|
||||
className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import type { ParticlePath } from '@/lib/particle-path';
|
||||
@@ -8,11 +8,8 @@ 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';
|
||||
@@ -20,7 +17,6 @@ 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' }>;
|
||||
@@ -37,6 +33,7 @@ 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;
|
||||
|
||||
@@ -54,6 +51,18 @@ 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">
|
||||
@@ -63,9 +72,7 @@ function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
||||
<LinkPreviewCardSkeleton />
|
||||
) : entry.metadata ? (
|
||||
<LinkPreviewCard metadata={entry.metadata} />
|
||||
) : (
|
||||
<LinkPreviewCardFallback url={entry.url} />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -97,14 +104,29 @@ export function TextParticleView({
|
||||
urls.length,
|
||||
attachments.length,
|
||||
);
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
useFixedDwell({
|
||||
id: particle.id,
|
||||
durationS,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
});
|
||||
// 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]);
|
||||
|
||||
// Content is just bare URLs with no surrounding text
|
||||
const contentTrimmed = content.trim();
|
||||
@@ -122,7 +144,7 @@ export function TextParticleView({
|
||||
setIsEditing(true);
|
||||
}}
|
||||
title="Edit"
|
||||
className="bg-card/70 text-muted-foreground border-border hover:bg-accent hover:text-foreground absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-30 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs backdrop-blur-sm transition-colors"
|
||||
className="absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 rounded-full bg-black/40 px-3 py-1.5 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
@@ -130,7 +152,7 @@ export function TextParticleView({
|
||||
);
|
||||
|
||||
const editedLabel = particle.properties.edited_at && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<span className="text-xs text-white/40">
|
||||
edited <RelativeTimestamp date={particle.properties.edited_at} />
|
||||
</span>
|
||||
);
|
||||
@@ -146,7 +168,7 @@ export function TextParticleView({
|
||||
// Mode 1: bare URLs only — show link cards centered
|
||||
if (linksOnly && !hasAttachments) {
|
||||
return (
|
||||
<div className="group relative flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
<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)]">
|
||||
<LinkPreviews entries={linkPreviews} />
|
||||
{editedLabel && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
|
||||
@@ -167,10 +189,10 @@ export function TextParticleView({
|
||||
) {
|
||||
const style = getImmersiveTextStyle(content.length);
|
||||
return (
|
||||
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 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)]">
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground max-w-2xl cursor-text select-text break-words text-center leading-relaxed',
|
||||
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text',
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
@@ -186,8 +208,18 @@ export function TextParticleView({
|
||||
|
||||
// Mode 3: card layout
|
||||
return (
|
||||
<div className="group relative flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
<div className="scrollbar-card bg-card text-card-foreground border-border 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 border p-[var(--message-card-padding)]">
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
<MarkdownEditor
|
||||
key={content}
|
||||
value={content}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function TextReactionInput({
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-popover/90 text-popover-foreground border-border flex items-center gap-1 rounded-full border py-1 pl-3 pr-1 shadow-lg backdrop-blur-md"
|
||||
className="flex items-center gap-1 rounded-full bg-black/60 py-1 pl-3 pr-1 shadow-lg ring-1 ring-white/15 backdrop-blur-md"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -74,14 +74,12 @@ export function TextReactionInput({
|
||||
}}
|
||||
placeholder="Quick reply…"
|
||||
maxLength={MAX_LENGTH}
|
||||
className="text-foreground placeholder:text-muted-foreground w-24 bg-transparent text-sm outline-none"
|
||||
className="w-24 bg-transparent text-sm text-white outline-none placeholder:text-white/40"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-[1.5ch] text-right text-[10px] tabular-nums',
|
||||
remaining <= 8
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-muted-foreground',
|
||||
remaining <= 8 ? 'text-amber-300/80' : 'text-white/30',
|
||||
)}
|
||||
>
|
||||
{remaining}
|
||||
@@ -93,8 +91,8 @@ export function TextReactionInput({
|
||||
className={cn(
|
||||
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
|
||||
canSubmit
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'text-muted-foreground',
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/30',
|
||||
)}
|
||||
aria-label="Send reaction"
|
||||
>
|
||||
|
||||
@@ -11,17 +11,10 @@ import {
|
||||
FileText,
|
||||
Volume2,
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette,
|
||||
} from 'lucide-react';
|
||||
import { HumanAvatar } from '@/components/human-avatar';
|
||||
import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Muted } from '@/components/ui/typography';
|
||||
@@ -29,7 +22,6 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CopyableEmail } from '@/components/copyable-email';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSoundEffectsStore } from '@/stores/sound-effects-store';
|
||||
import { useThemeStore, type ThemeMode } from '@/stores/theme-store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { logError, toUserMessage } from '@/lib/errors';
|
||||
import { toast } from 'sonner';
|
||||
@@ -86,50 +78,6 @@ function SettingsGroup({
|
||||
);
|
||||
}
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
value: ThemeMode;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{ value: 'system', label: 'System', icon: <Monitor className="size-3.5" /> },
|
||||
{ value: 'light', label: 'Light', icon: <Sun className="size-3.5" /> },
|
||||
{ value: 'dark', label: 'Dark', icon: <Moon className="size-3.5" /> },
|
||||
];
|
||||
|
||||
function ThemeSetting() {
|
||||
const mode = useThemeStore((s) => s.mode);
|
||||
const setMode = useThemeStore((s) => s.setMode);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
||||
<Palette className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">Theme</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
value={mode}
|
||||
onValueChange={(value) => value && setMode(value as ThemeMode)}
|
||||
className="border-border bg-muted/50 border p-0.5"
|
||||
spacing={2}
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
aria-label={option.label}
|
||||
className="data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm gap-1.5 rounded-md px-2.5 text-xs"
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -140,7 +88,6 @@ 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);
|
||||
@@ -188,39 +135,19 @@ export default function SettingsPage() {
|
||||
<ScrollArea className="flex-1">
|
||||
{/* Profile header */}
|
||||
<div className="flex items-center gap-3 px-4 py-5">
|
||||
<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="bg-scrim/40 absolute inset-0 flex items-center justify-center rounded-full 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>
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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="Appearance">
|
||||
<ThemeSetting />
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Notifications">
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Camera, Loader2, Trash2, Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Muted } from '@/components/ui/typography';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||
import { useFileInput } from '@/hooks/use-file-input';
|
||||
import { toAvatarBlob } from '@/lib/avatar-image';
|
||||
import { logError, toUserMessage } from '@/lib/errors';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Guard the *source* file before decoding so we never load a huge image into
|
||||
// memory just to throw most of it away — the uploaded blob is always our small
|
||||
// re-encoded square regardless of input size.
|
||||
const MAX_SOURCE_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
interface AvatarEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AvatarEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AvatarEditDialogProps) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const refreshUser = useAuthStore((s) => s.refreshUser);
|
||||
const currentUrl = useAvatarUrl(user?.avatar_object_id);
|
||||
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?';
|
||||
const hasAvatar = !!user?.avatar_object_id;
|
||||
|
||||
const [tab, setTab] = useState<'upload' | 'camera'>('upload');
|
||||
const [prepared, setPrepared] = useState<{ blob: Blob; url: string } | null>(
|
||||
null,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Keep the latest prepared blob in a ref so the unmount cleanup can revoke
|
||||
// its object URL without re-running on every change.
|
||||
const preparedRef = useRef(prepared);
|
||||
useEffect(() => {
|
||||
preparedRef.current = prepared;
|
||||
}, [prepared]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (preparedRef.current) URL.revokeObjectURL(preparedRef.current.url);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setPreparedFromBlob = useCallback((blob: Blob) => {
|
||||
setPrepared((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev.url);
|
||||
return { blob, url: URL.createObjectURL(blob) };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close and reset to a clean slate so reopening starts fresh.
|
||||
const close = useCallback(() => {
|
||||
setPrepared((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev.url);
|
||||
return null;
|
||||
});
|
||||
setTab('upload');
|
||||
setBusy(false);
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (next) onOpenChange(true);
|
||||
else close();
|
||||
},
|
||||
[close, onOpenChange],
|
||||
);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please choose an image file.');
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_SOURCE_BYTES) {
|
||||
toast.error('That image is too large — choose one under 30MB.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const blob = await toAvatarBlob(bitmap);
|
||||
bitmap.close();
|
||||
setPreparedFromBlob(blob);
|
||||
} catch (err) {
|
||||
toast.error('Could not process that image.');
|
||||
logError(err, { scope: 'avatar.processFile' });
|
||||
}
|
||||
},
|
||||
[setPreparedFromBlob],
|
||||
);
|
||||
|
||||
const { openFilePicker, isDragging, dropZoneProps } = useFileInput({
|
||||
onFilesSelected: handleFiles,
|
||||
enabled: open && tab === 'upload',
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!prepared) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiClient.updateAvatar(prepared.blob);
|
||||
await refreshUser();
|
||||
toast.success('Avatar updated');
|
||||
close();
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
logError(err, { scope: 'avatar.save' });
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiClient.deleteAvatar();
|
||||
await refreshUser();
|
||||
toast.success('Avatar removed');
|
||||
close();
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
logError(err, { scope: 'avatar.remove' });
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewUrl = prepared?.url ?? currentUrl;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Profile picture</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload an image or take one with your camera.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 py-1">
|
||||
<div className="bg-muted flex size-24 items-center justify-center overflow-hidden rounded-full border">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<span className="text-muted-foreground text-2xl font-medium">
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as 'upload' | 'camera')}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="upload">
|
||||
<Upload />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="camera">
|
||||
<Camera />
|
||||
Take photo
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upload" className="pt-3">
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-5 text-center transition-colors',
|
||||
isDragging && 'border-primary bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<Muted className="text-xs">Drag an image here, or</Muted>
|
||||
<Button variant="outline" size="sm" onClick={openFilePicker}>
|
||||
Choose image
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="camera" className="pt-3">
|
||||
<CameraCapture
|
||||
active={open && tab === 'camera'}
|
||||
onCapture={setPreparedFromBlob}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{hasAvatar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive sm:mr-auto"
|
||||
onClick={handleRemove}
|
||||
disabled={busy}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={close} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
|
||||
{busy && <Loader2 className="mr-1 size-3.5 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraCapture({
|
||||
active,
|
||||
onCapture,
|
||||
}: {
|
||||
active: boolean;
|
||||
onCapture: (blob: Blob) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
// Honor the camera the user picked in Audio & Video settings. `ideal` rather
|
||||
// than `exact` so a since-unplugged device falls back to the default instead
|
||||
// of throwing OverconstrainedError.
|
||||
const savedCameraId = useMediaDevicesStore((s) => s.camera?.deviceId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
let cancelled = false;
|
||||
let acquired: MediaStream | null = null;
|
||||
|
||||
const video: MediaTrackConstraints = { aspectRatio: { ideal: 1 } };
|
||||
if (savedCameraId) video.deviceId = { ideal: savedCameraId };
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video, audio: false })
|
||||
.then((s) => {
|
||||
if (cancelled) {
|
||||
s.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
acquired = s;
|
||||
setStream(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'Unable to access camera',
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
acquired?.getTracks().forEach((t) => t.stop());
|
||||
setStream(null);
|
||||
};
|
||||
}, [active, savedCameraId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) videoRef.current.srcObject = stream;
|
||||
}, [stream]);
|
||||
|
||||
const handleCapture = async () => {
|
||||
const video = videoRef.current;
|
||||
// readyState < HAVE_CURRENT_DATA (or zero dimensions) means no frame has
|
||||
// decoded yet — capturing now would grab a blank image.
|
||||
if (!video || video.readyState < 2 || !video.videoWidth) {
|
||||
toast.error('Camera is still starting — try again in a moment.');
|
||||
return;
|
||||
}
|
||||
setCapturing(true);
|
||||
try {
|
||||
const bitmap = await createImageBitmap(video);
|
||||
// Mirror to match the (mirrored) live preview the user is looking at.
|
||||
const blob = await toAvatarBlob(bitmap, { mirror: true });
|
||||
bitmap.close();
|
||||
onCapture(blob);
|
||||
} catch (err) {
|
||||
toast.error('Could not capture photo.');
|
||||
logError(err, { scope: 'avatar.capture' });
|
||||
} finally {
|
||||
setCapturing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-lg border border-dashed px-6 py-5 text-center">
|
||||
<Muted className="text-xs">Couldn't access your camera.</Muted>
|
||||
<Muted className="text-[11px]">{error}</Muted>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="bg-muted size-36 overflow-hidden rounded-full border">
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
className="size-full -scale-x-100 object-cover"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleCapture} disabled={!stream || capturing}>
|
||||
<Camera className="mr-1 size-3.5" />
|
||||
Capture
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useQuery, skipToken } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/api/client';
|
||||
|
||||
/**
|
||||
* Resolves an avatar object id to a signed download URL. Mirrors
|
||||
* {@link import('./use-download-url').useDownloadUrl} — React Query handles
|
||||
* caching and de-duping, so many avatars sharing an id make a single request.
|
||||
*/
|
||||
export function useAvatarUrl(
|
||||
objectId: string | null | undefined,
|
||||
): string | undefined {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['avatar-url', objectId],
|
||||
queryFn: objectId
|
||||
? () => apiClient.getAvatarDownloadUrl(objectId)
|
||||
: skipToken,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -29,8 +29,6 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||
type: T;
|
||||
properties: ParticlePropertiesMap[T];
|
||||
createdByHumanId: string;
|
||||
// Required for container types
|
||||
visibleTo?: string[];
|
||||
}
|
||||
|
||||
export function useCreateParticle() {
|
||||
@@ -61,7 +59,6 @@ export function useCreateParticle() {
|
||||
params.type,
|
||||
params.properties,
|
||||
params.createdByHumanId,
|
||||
params.visibleTo,
|
||||
);
|
||||
|
||||
if (!CONTAINER_TYPES.has(params.type)) {
|
||||
@@ -79,17 +76,15 @@ 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 = params.parentPath ?? particlePath(params.networkId, []);
|
||||
const collectionPath = toFirestoreChildrenPath(path);
|
||||
const path = particlePath(params.networkId, []);
|
||||
const networkCollectionPath = toFirestoreChildrenPath(path);
|
||||
return await createStreamParticle(
|
||||
collectionPath,
|
||||
networkCollectionPath,
|
||||
params.properties,
|
||||
params.createdByHumanId,
|
||||
params.visibleTo,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { transferFiles } from '@/lib/data-transfer';
|
||||
|
||||
interface UseFileInputOptions {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
@@ -49,27 +48,16 @@ export function useFileInput({
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Capture phase so file pastes always become attachments — the markdown
|
||||
// editor would otherwise inline a pasted image as an ephemeral blob: URL.
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const data = e.clipboardData;
|
||||
if (!data) return;
|
||||
|
||||
// Actual text means "paste as text", even when the clipboard also ships
|
||||
// an image rendition (Excel/Word, or "copy image" from a page with alt
|
||||
// text). Test the payload, not the advertised types: image pastes often
|
||||
// list an *empty* text/plain entry that must not block the attachment.
|
||||
if (data.getData('text/plain').trim().length > 0) return;
|
||||
|
||||
const files = transferFiles(data);
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFilesRef.current(files);
|
||||
const files = Array.from(e.clipboardData?.files ?? []);
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
onFilesRef.current(files);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('paste', handlePaste, true);
|
||||
return () => window.removeEventListener('paste', handlePaste, true);
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => window.removeEventListener('paste', handlePaste);
|
||||
}, [enabled]);
|
||||
|
||||
// Drag and drop handlers
|
||||
@@ -111,8 +99,7 @@ export function useFileInput({
|
||||
e.stopPropagation();
|
||||
dragCountRef.current = 0;
|
||||
setIsDragging(false);
|
||||
|
||||
const files = transferFiles(e.dataTransfer);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
onFilesRef.current(files);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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,85 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import {
|
||||
@@ -21,10 +15,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,11 +31,6 @@ export function usePlaybackKeys({
|
||||
|
||||
useSuspendPlayback(spaceHeld, 'hold-space');
|
||||
|
||||
const resume = useCallback(() => {
|
||||
setSpaceHeld(false);
|
||||
spaceStartRef.current = 0;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isExternallyPaused = () =>
|
||||
selectIsPaused(usePlaybackPauseStore.getState()) && !spaceHeld;
|
||||
@@ -107,5 +92,5 @@ export function usePlaybackKeys({
|
||||
};
|
||||
}, [mediaRef, spaceHeld]);
|
||||
|
||||
return { fastPlayback, spacePaused: spaceHeld, resume };
|
||||
return { fastPlayback };
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface HumanPresence {
|
||||
humanId: string;
|
||||
email: string;
|
||||
emailPrefix: string;
|
||||
avatarObjectId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +42,6 @@ export function usePresencePositions(
|
||||
humanId: human.id,
|
||||
email: human.email,
|
||||
emailPrefix: human.email_prefix,
|
||||
avatarObjectId: human.avatar_object_id ?? null,
|
||||
};
|
||||
if (existing) {
|
||||
existing.push(presence);
|
||||
|
||||
+21
-25
@@ -1,33 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
|
||||
interface UseListKeyboardNavOptions {
|
||||
items: Array<{ id: string }>;
|
||||
interface UseStreamKeyboardNavOptions {
|
||||
streams: Array<{ id: string }>;
|
||||
enabled: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onNavigate: (streamId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared keyboard grammar for browsable lists (network root, folders):
|
||||
* ↑/↓ move selection, Enter opens, 1–9 jump, V toggles video/audio.
|
||||
*/
|
||||
export function useListKeyboardNav({
|
||||
items,
|
||||
export function useStreamKeyboardNav({
|
||||
streams,
|
||||
enabled,
|
||||
onOpen,
|
||||
}: UseListKeyboardNavOptions) {
|
||||
onNavigate,
|
||||
}: UseStreamKeyboardNavOptions) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(
|
||||
items.length > 0 ? 0 : null,
|
||||
streams.length > 0 ? 0 : null,
|
||||
);
|
||||
const [prevItemCount, setPrevItemCount] = useState(items.length);
|
||||
const [prevStreamCount, setPrevStreamCount] = useState(streams.length);
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||
|
||||
// Select the first item once they load and clear when empty — but not on
|
||||
// Select the first stream once they load and clear when empty — but not on
|
||||
// every Firestore update, which would scroll the list back to the top.
|
||||
if (items.length !== prevItemCount) {
|
||||
setPrevItemCount(items.length);
|
||||
if (items.length === 0) {
|
||||
if (streams.length !== prevStreamCount) {
|
||||
setPrevStreamCount(streams.length);
|
||||
if (streams.length === 0) {
|
||||
setSelectedIndex(null);
|
||||
} else if (selectedIndex === null) {
|
||||
setSelectedIndex(0);
|
||||
@@ -35,7 +31,7 @@ export function useListKeyboardNav({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || items.length === 0) return;
|
||||
if (!enabled || streams.length === 0) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
@@ -53,19 +49,19 @@ export function useListKeyboardNav({
|
||||
const digit = parseInt(e.key, 10);
|
||||
if (digit >= 1 && digit <= 9) {
|
||||
const index = digit - 1;
|
||||
if (index < items.length) {
|
||||
if (index < streams.length) {
|
||||
e.preventDefault();
|
||||
onOpen(items[index].id);
|
||||
onNavigate(streams[index].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: open selected
|
||||
// Enter: navigate to selected
|
||||
if (e.key === 'Enter') {
|
||||
setSelectedIndex((idx) => {
|
||||
if (idx !== null && idx < items.length) {
|
||||
if (idx !== null && idx < streams.length) {
|
||||
e.preventDefault();
|
||||
onOpen(items[idx].id);
|
||||
onNavigate(streams[idx].id);
|
||||
}
|
||||
return idx;
|
||||
});
|
||||
@@ -90,7 +86,7 @@ export function useListKeyboardNav({
|
||||
setSelectedIndex((prev) => {
|
||||
if (prev === null) return 0;
|
||||
const next = prev + delta;
|
||||
return Math.max(0, Math.min(next, items.length - 1));
|
||||
return Math.max(0, Math.min(next, streams.length - 1));
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -99,7 +95,7 @@ export function useListKeyboardNav({
|
||||
// components (ToggleGroup, etc.) consume them for their own navigation.
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [enabled, items, onOpen, recordingMode, setRecordingMode]);
|
||||
}, [enabled, streams, onNavigate, recordingMode, setRecordingMode]);
|
||||
|
||||
return { selectedIndex };
|
||||
}
|
||||
@@ -11,7 +11,6 @@ interface UseStreamNavigationKeysOptions {
|
||||
childrenLength: number;
|
||||
mediaRef: RefObject<MediaParticleHandle | null>;
|
||||
onExit: () => void;
|
||||
onToggleViewMode: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +24,6 @@ export function useStreamNavigationKeys({
|
||||
childrenLength,
|
||||
mediaRef,
|
||||
onExit,
|
||||
onToggleViewMode,
|
||||
}: UseStreamNavigationKeysOptions) {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -56,25 +54,10 @@ 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,
|
||||
onToggleViewMode,
|
||||
]);
|
||||
}, [next, prev, currentIndex, childrenLength, mediaRef, onExit]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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 };
|
||||
}
|
||||
@@ -98,26 +98,12 @@ interface UseStreamPlaybackResult {
|
||||
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);
|
||||
// Read via ref so the onAdded subscription callback stays stable.
|
||||
const autoAdvanceOnNewRef = useRef(autoAdvanceOnNew);
|
||||
useEffect(() => {
|
||||
autoAdvanceOnNewRef.current = autoAdvanceOnNew;
|
||||
}, [autoAdvanceOnNew]);
|
||||
// Track the stream ID we've initialized for, to reset when navigating between streams
|
||||
const initializedForRef = useRef<string | null>(null);
|
||||
// Latest currentIndex for onParticleRemoved, which is passed into
|
||||
@@ -129,7 +115,6 @@ export function useStreamPlayback(
|
||||
|
||||
// --- Firestore change callbacks ---
|
||||
const onParticleAdded = useCallback((particle: Particle) => {
|
||||
if (!autoAdvanceOnNewRef.current) return;
|
||||
dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Center-crop an image source to a square and downscale it to a JPEG suitable
|
||||
* for an avatar. Avatars never render larger than ~80px, so 512px is generous
|
||||
* headroom while keeping the upload to a few tens of KB regardless of input.
|
||||
*
|
||||
* Pass `mirror` when capturing from a (mirrored) webcam preview so the saved
|
||||
* image matches what the user saw.
|
||||
*/
|
||||
export async function toAvatarBlob(
|
||||
source: ImageBitmap,
|
||||
{ size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
|
||||
): Promise<Blob> {
|
||||
if (!Number.isInteger(size) || size <= 0) {
|
||||
throw new Error('Avatar size must be a positive integer');
|
||||
}
|
||||
const side = Math.min(source.width, source.height);
|
||||
if (side === 0) {
|
||||
// A not-yet-decoded <video> or a corrupt image yields a zero-size source;
|
||||
// cropping it would silently produce a blank avatar, so fail instead.
|
||||
throw new Error('Image source has zero dimensions');
|
||||
}
|
||||
const sx = (source.width - side) / 2;
|
||||
const sy = (source.height - side) / 2;
|
||||
|
||||
const canvas = new OffscreenCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Failed to acquire 2D canvas context');
|
||||
|
||||
if (mirror) {
|
||||
ctx.translate(size, 0);
|
||||
ctx.scale(-1, 1);
|
||||
}
|
||||
ctx.drawImage(source, sx, sy, side, side, 0, 0, size, size);
|
||||
|
||||
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.82 });
|
||||
}
|
||||
@@ -4,15 +4,6 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
||||
/** Maximum number of file attachments per particle. */
|
||||
export const MAX_ATTACHMENTS = 10;
|
||||
|
||||
/** Maximum recommended duration for a media (audio/video) recording, in seconds. */
|
||||
export const RECORDING_MAX_DURATION_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Minimum time the record key must be held to count as hold-to-record (vs. a
|
||||
* quick tap that toggles), in milliseconds.
|
||||
*/
|
||||
export const HOLD_THRESHOLD_MS = 250;
|
||||
|
||||
export const SUPPORT_EMAIL = 'team@flowylabs.ai';
|
||||
|
||||
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Files carried by a paste or drag {@link DataTransfer}. Pasted/dragged images
|
||||
* frequently surface only through `items` (`getAsFile`), with `files` left
|
||||
* empty, so read `items` first and fall back to `files`.
|
||||
*/
|
||||
export function transferFiles(data: DataTransfer): File[] {
|
||||
const fromItems = Array.from(data.items)
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
return fromItems.length > 0 ? fromItems : Array.from(data.files);
|
||||
}
|
||||
@@ -23,11 +23,7 @@ import {
|
||||
QueryFieldFilterConstraint,
|
||||
} from 'firebase/firestore';
|
||||
import { firestoreDb } from '@/firebase';
|
||||
import {
|
||||
isContainerType,
|
||||
ParticleSchema,
|
||||
UnknownParticleSchema,
|
||||
} from '@/api/types';
|
||||
import { isContainerType, ParticleSchema } from '@/api/types';
|
||||
import type {
|
||||
Particle,
|
||||
ParticleType,
|
||||
@@ -37,22 +33,6 @@ 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;
|
||||
@@ -80,7 +60,6 @@ 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
|
||||
@@ -100,6 +79,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
: undefined,
|
||||
huddle_active_participants:
|
||||
raw.huddle_active_participants ?? undefined,
|
||||
status: raw.status ?? undefined,
|
||||
});
|
||||
case 'folder':
|
||||
return ParticleSchema.parse({
|
||||
@@ -112,16 +92,21 @@ 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 'task':
|
||||
case 'quest':
|
||||
case 'paper': {
|
||||
const properties = coerceLeafPropertyDates(type, raw.properties);
|
||||
// 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;
|
||||
return ParticleSchema.parse({
|
||||
id: snap.id,
|
||||
type: raw.type,
|
||||
@@ -139,33 +124,11 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
});
|
||||
}
|
||||
default:
|
||||
// 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,
|
||||
});
|
||||
throw new Error(`Unknown particle type: ${type}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 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) {
|
||||
@@ -186,7 +149,7 @@ export function subscribeToParticle(
|
||||
return onSnapshot(
|
||||
typedDoc(docPath),
|
||||
(snap) => {
|
||||
onData(snap.exists() ? safeData(snap) : null);
|
||||
onData(snap.exists() ? snap.data() : null);
|
||||
},
|
||||
onError,
|
||||
);
|
||||
@@ -198,7 +161,7 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
return safeData(doc);
|
||||
return doc.data();
|
||||
}
|
||||
|
||||
export interface GetParticleChildrenOptions {
|
||||
@@ -221,7 +184,7 @@ export async function getParticleChildren(
|
||||
orderBy(orderByField, orderDirection),
|
||||
);
|
||||
const snap = await getDocs(q);
|
||||
return snap.docs.flatMap((d) => safeData(d) ?? []);
|
||||
return snap.docs.map((d) => d.data());
|
||||
}
|
||||
|
||||
export interface SubscribeToParticleChildrenOptions {
|
||||
@@ -267,16 +230,14 @@ export function subscribeToParticleChildren(
|
||||
return onSnapshot(
|
||||
q,
|
||||
(snap) => {
|
||||
const updatedChildren = snap.docs.flatMap((d) => safeData(d) ?? []);
|
||||
const updatedChildren = snap.docs.map((d) => d.data());
|
||||
onData(updatedChildren);
|
||||
|
||||
if (onAdded || onRemoved) {
|
||||
for (const change of snap.docChanges()) {
|
||||
const child = safeData(change.doc);
|
||||
if (!child) continue;
|
||||
if (change.type === 'added' && onAdded) onAdded(child);
|
||||
if (change.type === 'added' && onAdded) onAdded(change.doc.data());
|
||||
if (change.type === 'removed' && onRemoved)
|
||||
onRemoved(child, updatedChildren);
|
||||
onRemoved(change.doc.data(), updatedChildren);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -297,31 +258,12 @@ export function subscribeToLatestChild(
|
||||
return onSnapshot(
|
||||
q,
|
||||
(snap) => {
|
||||
onData(snap.empty ? null : safeData(snap.docs[0]));
|
||||
onData(snap.empty ? null : snap.docs[0].data());
|
||||
},
|
||||
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,
|
||||
@@ -337,21 +279,15 @@ 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: createdAt,
|
||||
created_at: new Date(),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -365,32 +301,19 @@ 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,
|
||||
status: 'open',
|
||||
created_at: createdAt,
|
||||
created_at: new Date(),
|
||||
created_by_human_id: createdByHumanId,
|
||||
visible_to: visibleTo,
|
||||
last_child_created_at: createdAt,
|
||||
status: 'open',
|
||||
});
|
||||
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,
|
||||
@@ -459,6 +382,14 @@ 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
|
||||
|
||||
@@ -13,8 +13,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +32,6 @@ export function resolveHumanDisplay(
|
||||
displayName: REMOVED_MEMBER_LABEL,
|
||||
email: REMOVED_MEMBER_LABEL,
|
||||
initials: REMOVED_MEMBER_INITIALS,
|
||||
avatarObjectId: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -42,6 +39,5 @@ export function resolveHumanDisplay(
|
||||
displayName: human.email_prefix,
|
||||
email: human.email,
|
||||
initials: getInitials(human.email),
|
||||
avatarObjectId: human.avatar_object_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/** 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' };
|
||||
}
|
||||
@@ -14,11 +14,3 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export function hasMarkdownFormatting(content: string): boolean {
|
||||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*\S[^*]*\*|\b_\S[^_]*_\b|^>/m.test(
|
||||
content,
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { initSentryRenderer } from '@/lib/sentry';
|
||||
import '@/stores/theme-store';
|
||||
import './styles/globals.css';
|
||||
|
||||
initSentryRenderer();
|
||||
|
||||
@@ -30,7 +30,6 @@ 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>;
|
||||
@@ -65,11 +64,6 @@ 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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user