Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa380ac76 | |||
| 591da5dd7b | |||
| b08cb796d7 | |||
| 2919cb12b2 | |||
| d60b948262 | |||
| fec7f5d6d8 | |||
| d14cec5885 | |||
| 3d80ac2993 | |||
| 428f9ea1c9 | |||
| f2f8602263 | |||
| 661577ecd2 | |||
| c6ce1bdc65 | |||
| bfe53b46cf | |||
| 129e4772dd | |||
| 92c12ad0bd | |||
| c11c5074ce | |||
| fa9f88f6f6 | |||
| 9249cb4107 | |||
| 095b9876f9 | |||
| a358774106 | |||
| d24947d5ce | |||
| 9e3a30f74e | |||
| 4facc6b371 | |||
| ca3bbf204e | |||
| 66d53497c5 |
@@ -79,7 +79,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
if [ "${{ inputs.module }}" = "migrations" ]; then
|
if [ "${{ inputs.module }}" = "migrations" ]; then
|
||||||
kubectl delete job migrations --ignore-not-found
|
kubectl delete job migrations --ignore-not-found
|
||||||
skaffold run -p migrations --tail
|
skaffold run -p migrations
|
||||||
else
|
else
|
||||||
skaffold run -p ${{ inputs.environment }} -m ${{ inputs.module }}
|
skaffold run -p ${{ inputs.environment }} -m ${{ inputs.module }}
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ build/
|
|||||||
compile_commands.json
|
compile_commands.json
|
||||||
CMakeLists.txt.user
|
CMakeLists.txt.user
|
||||||
tags
|
tags
|
||||||
|
project.el
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
# Project Rules
|
# Project Rules
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
- Electron typescript/react app is in `js/` folder
|
- Electron typescript/react app is in `js/desktop` folder
|
||||||
- Orion is the api server which lives in the `go/` folder
|
- Mobile react native expo app is in `js/mobile`
|
||||||
|
- Orion is the api server which lives in the `go/` folder along with other workers, jobs, services
|
||||||
- `cpp/` points to our prototype of a C++ Qt widgets client
|
- `cpp/` points to our prototype of a C++ Qt widgets client
|
||||||
|
|
||||||
Whenever implementing anything, make sure to take into account best practices without over-engineering.
|
Whenever implementing anything, make sure to take into account best practices without over-engineering.
|
||||||
|
|
||||||
## Electron App
|
## Electron App
|
||||||
### Quality
|
### Quality
|
||||||
We care about overall architectural quality and keeping consistent patterns according to best practices.
|
We care about overall architectural quality and keeping consistent patterns according to best practices. Feel free to move stuff around to make things more elegant, instead of bolting on features.
|
||||||
|
|
||||||
Another tradeoff we make is simpler, maintainable code over clever behavior.
|
Another tradeoff we make is simpler, maintainable code over clever behavior.
|
||||||
|
|
||||||
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
|
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
Don't add non-essential comments everywhere. Clean them up if so.
|
||||||
|
|
||||||
### Package Manager
|
### Package Manager
|
||||||
- Use **yarn** (not npm) for all dependency management
|
- Use **yarn** (not npm) for all dependency management
|
||||||
|
|
||||||
@@ -25,6 +29,3 @@ Whenever possible, we should use the design system components. If we need to add
|
|||||||
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
|
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
|
||||||
|
|
||||||
IF you find that the API is poorly designed, please suggest changes to improve the client experience.
|
IF you find that the API is poorly designed, please suggest changes to improve the client experience.
|
||||||
|
|
||||||
## Verification
|
|
||||||
Check using `yarn compile` which lives in the package.json as a script.
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Flowy.llink
|
||||||
|
|
||||||
|
This project was a radical experiment to transform how teams communicate. The experience revolves around async voice and video clips, with the playback experience being somewhat like Instagram stories that auto-play.
|
||||||
|
|
||||||
|
The goal was to replace live Zoom-style meetings, which are draining, and bring relief to teams by making more conversations async, while retaining natural and high-fidelity with pitch, tone, facial expressions.
|
||||||
|
|
||||||
|
Due to changing conditions in the environment with regards to AI and data sovereignty (and lack of desire to maintain and market this product), we have decided to open source it. Now you can clone, modify, and host it in your own cloud.
|
||||||
+2
-2
@@ -38,12 +38,12 @@ PROD_REPO := us-west2-docker.pkg.dev/flowy-prod-440017/deployments
|
|||||||
.PHONY: migrate-dev
|
.PHONY: migrate-dev
|
||||||
migrate-dev:
|
migrate-dev:
|
||||||
kubectl delete job migrations --ignore-not-found --context=dev
|
kubectl delete job migrations --ignore-not-found --context=dev
|
||||||
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev --tail
|
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev
|
||||||
|
|
||||||
.PHONY: migrate-prod
|
.PHONY: migrate-prod
|
||||||
migrate-prod:
|
migrate-prod:
|
||||||
kubectl delete job migrations --ignore-not-found --context=prod
|
kubectl delete job migrations --ignore-not-found --context=prod
|
||||||
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod --tail
|
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod
|
||||||
|
|
||||||
# ---- Deploy ----
|
# ---- Deploy ----
|
||||||
# Use MODULE=orion or MODULE=particleprocessor or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.:
|
# Use MODULE=orion or MODULE=particleprocessor or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.:
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
vars:
|
|
||||||
DEV_REPO: us-west2-docker.pkg.dev/flowy-dev-440017/deployments
|
|
||||||
PROD_REPO: us-west2-docker.pkg.dev/flowy-prod-440017/deployments
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
generate:
|
|
||||||
desc: Generate protobuf code and run go generate
|
|
||||||
cmds:
|
|
||||||
- task: genproto
|
|
||||||
- go generate ./...
|
|
||||||
|
|
||||||
format:
|
|
||||||
desc: Format go files
|
|
||||||
cmds:
|
|
||||||
- go fmt ./...
|
|
||||||
|
|
||||||
genproto:
|
|
||||||
desc: Generate Go code from .proto files via docker
|
|
||||||
vars:
|
|
||||||
PROTO_DIR: '{{.PROTO_DIR | default "./protocol"}}'
|
|
||||||
OUT_DIR: '{{.OUT_DIR | default "./genproto"}}'
|
|
||||||
cmds:
|
|
||||||
- 'echo "Proto directory: {{.PROTO_DIR}}"'
|
|
||||||
- 'echo "Output directory: {{.OUT_DIR}}"'
|
|
||||||
- rm -rf {{.OUT_DIR}}
|
|
||||||
- mkdir -p {{.OUT_DIR}}
|
|
||||||
- |
|
|
||||||
docker run --rm \
|
|
||||||
-v "{{.PROTO_DIR}}/":/protocol \
|
|
||||||
-v "{{.OUT_DIR}}":/genproto \
|
|
||||||
--workdir / \
|
|
||||||
talksik/golang-protoc:latest \
|
|
||||||
sh -c 'protoc --proto_path=./protocol \
|
|
||||||
--go_out=./genproto \
|
|
||||||
--go_opt=paths=source_relative \
|
|
||||||
--go-grpc_out=./genproto \
|
|
||||||
--go-grpc_opt=paths=source_relative \
|
|
||||||
$(find ./protocol -name "*.proto")'
|
|
||||||
|
|
||||||
test:
|
|
||||||
desc: Run tests with pretty output via gotestfmt
|
|
||||||
cmds:
|
|
||||||
- go test -json ./... | docker run -i ghcr.io/gotesttools/gotestfmt:latest
|
|
||||||
|
|
||||||
create-migration:
|
|
||||||
desc: 'Create new migration (usage: task create-migration ARG=create_users_table)'
|
|
||||||
cmds:
|
|
||||||
- 'echo "Creating migration with name {{.ARG}}"'
|
|
||||||
- docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations {{.ARG}}
|
|
||||||
|
|
||||||
dev-database:
|
|
||||||
desc: Open psql shell against the dev database
|
|
||||||
interactive: true
|
|
||||||
cmds:
|
|
||||||
- |
|
|
||||||
kubectl run postgres-client \
|
|
||||||
--rm -it --image=postgres:latest \
|
|
||||||
--env="LLINK_POSTGRES_CONNECTION_URL=$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context dev | base64 --decode)" \
|
|
||||||
--context dev \
|
|
||||||
--command -- /bin/bash -c "psql \$LLINK_POSTGRES_CONNECTION_URL"
|
|
||||||
|
|
||||||
prod-database:
|
|
||||||
desc: Open psql shell against the prod database
|
|
||||||
interactive: true
|
|
||||||
cmds:
|
|
||||||
- |
|
|
||||||
kubectl run postgres-client \
|
|
||||||
--rm -it --image=postgres:latest \
|
|
||||||
--env="LLINK_POSTGRES_CONNECTION_URL=$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context prod | base64 --decode)" \
|
|
||||||
--context prod \
|
|
||||||
--command -- /bin/bash -c "psql \$LLINK_POSTGRES_CONNECTION_URL"
|
|
||||||
|
|
||||||
migrate-dev:
|
|
||||||
desc: Run migrations against dev cluster
|
|
||||||
cmds:
|
|
||||||
- kubectl delete job migrations --ignore-not-found --context=dev
|
|
||||||
- SKAFFOLD_DEFAULT_REPO={{.DEV_REPO}} skaffold run -p migrations --kube-context dev --tail
|
|
||||||
|
|
||||||
migrate-prod:
|
|
||||||
desc: Run migrations against prod cluster
|
|
||||||
cmds:
|
|
||||||
- kubectl delete job migrations --ignore-not-found --context=prod
|
|
||||||
- SKAFFOLD_DEFAULT_REPO={{.PROD_REPO}} skaffold run -p migrations --kube-context prod --tail
|
|
||||||
|
|
||||||
deploy-dev:
|
|
||||||
desc: 'Deploy to dev (optionally MODULE=orion to deploy a single service)'
|
|
||||||
cmds:
|
|
||||||
- task: generate
|
|
||||||
- task: test
|
|
||||||
- SKAFFOLD_DEFAULT_REPO={{.DEV_REPO}} skaffold run -p dev {{if .MODULE}}-m {{.MODULE}}{{end}} --kube-context dev --port-forward --tail
|
|
||||||
|
|
||||||
deploy-prod:
|
|
||||||
desc: 'Deploy to prod (optionally MODULE=orion to deploy a single service)'
|
|
||||||
cmds:
|
|
||||||
- task: generate
|
|
||||||
- task: test
|
|
||||||
- SKAFFOLD_DEFAULT_REPO={{.PROD_REPO}} skaffold run -p prod {{if .MODULE}}-m {{.MODULE}}{{end}} --kube-context prod --port-forward --tail
|
|
||||||
@@ -147,6 +147,11 @@ func main() {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
||||||
|
mux.Handle("PUT /humans/me/avatar", withAuth(h.UpdateAvatar))
|
||||||
|
mux.Handle("DELETE /humans/me/avatar", withAuth(h.DeleteAvatar))
|
||||||
|
|
||||||
|
// Get avatar download url, given objectId
|
||||||
|
mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl))
|
||||||
|
|
||||||
// Push notification tokens (per-device)
|
// Push notification tokens (per-device)
|
||||||
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
|
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
|
||||||
@@ -174,7 +179,7 @@ func main() {
|
|||||||
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
|
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
|
||||||
|
|
||||||
// Particles
|
// Particles
|
||||||
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
|
mux.Handle("GET /particles/{id}/download", withAuth(h.GetObjectDownloadUrl))
|
||||||
|
|
||||||
// Link metadata
|
// Link metadata
|
||||||
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
// Per-human membership mirror maintained by Orion's service account.
|
||||||
|
// Locked from clients so the membership list cannot leak or be mutated.
|
||||||
|
match /humans/{humanId} {
|
||||||
|
allow read, write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Particles: caller must be a current member of the network in the path.
|
||||||
|
// get() on the mirror is cached across rule evaluations within a single
|
||||||
|
// request, so this costs 1 billed read per request regardless of query size.
|
||||||
|
match /networks/{networkId}/children/{particleId=**} {
|
||||||
|
allow read, write: if request.auth != null
|
||||||
|
&& networkId in get(/databases/$(database)/documents/humans/$(request.auth.uid)).data.networks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
resource "google_firebase_project" "default" {
|
||||||
|
provider = google-beta
|
||||||
|
project = var.project_id
|
||||||
|
|
||||||
|
depends_on = [module.project-services]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Provisions the Firestore database instance.
|
||||||
|
resource "google_firestore_database" "firestore" {
|
||||||
|
provider = google-beta
|
||||||
|
project = google_firebase_project.default.project
|
||||||
|
name = "(default)"
|
||||||
|
# See available locations: https://firebase.google.com/docs/firestore/locations
|
||||||
|
location_id = var.location
|
||||||
|
# "FIRESTORE_NATIVE" is required to use Firestore with Firebase SDKs, authentication, and Firebase Security Rules.
|
||||||
|
type = "FIRESTORE_NATIVE"
|
||||||
|
concurrency_mode = "OPTIMISTIC"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_firebaserules_ruleset" "default" {
|
||||||
|
provider = google-beta
|
||||||
|
project = google_firestore_database.firestore.project
|
||||||
|
source {
|
||||||
|
files {
|
||||||
|
name = "firestore.rules"
|
||||||
|
# Write security rules in a local file named "firestore.rules".
|
||||||
|
# Learn more: https://firebase.google.com/docs/firestore/security/get-started
|
||||||
|
content = file("firestore.rules")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_firebaserules_release" "firestore" {
|
||||||
|
provider = google-beta
|
||||||
|
name = "cloud.firestore"
|
||||||
|
ruleset_name = google_firebaserules_ruleset.default.name
|
||||||
|
project = google_firestore_database.firestore.project
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_firebase_web_app" "llink" {
|
||||||
|
provider = google-beta
|
||||||
|
project = google_firebase_project.default.project
|
||||||
|
display_name = "Llink app"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "google_firebase_web_app_config" "llink" {
|
||||||
|
provider = google-beta
|
||||||
|
project = var.project_id
|
||||||
|
web_app_id = google_firebase_web_app.llink.app_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below indices support the new structure where all subcollections (including network root particles) live under a collection of name "children"
|
||||||
|
// instead of having `network/n_xxx/particles/p_yyy/children` where the top level subcollection of particles has a distinct name
|
||||||
|
|
||||||
|
// Collection index for particles in a single subcollection
|
||||||
|
resource "google_firestore_index" "list_particles_index" {
|
||||||
|
project = var.project_id
|
||||||
|
collection = "children"
|
||||||
|
query_scope = "COLLECTION"
|
||||||
|
|
||||||
|
fields {
|
||||||
|
field_path = "visible_to"
|
||||||
|
array_config = "CONTAINS"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields {
|
||||||
|
field_path = "status"
|
||||||
|
order = "ASCENDING"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields {
|
||||||
|
field_path = "last_child_created_at"
|
||||||
|
order = "DESCENDING"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields {
|
||||||
|
field_path = "__name__"
|
||||||
|
order = "DESCENDING"
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [google_firestore_database.firestore]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection group index for particles across all subcollections
|
||||||
|
resource "google_firestore_field" "particle_children_created_at" {
|
||||||
|
database = google_firestore_database.firestore.name
|
||||||
|
project = var.project_id
|
||||||
|
collection = "children"
|
||||||
|
field = "created_at"
|
||||||
|
|
||||||
|
index_config {
|
||||||
|
indexes {
|
||||||
|
order = "DESCENDING"
|
||||||
|
query_scope = "COLLECTION_GROUP"
|
||||||
|
}
|
||||||
|
indexes {
|
||||||
|
order = "ASCENDING"
|
||||||
|
query_scope = "COLLECTION_GROUP"
|
||||||
|
}
|
||||||
|
indexes {
|
||||||
|
order = "DESCENDING"
|
||||||
|
query_scope = "COLLECTION"
|
||||||
|
}
|
||||||
|
indexes {
|
||||||
|
order = "ASCENDING"
|
||||||
|
query_scope = "COLLECTION"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ type Human struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
EmailPrefix string `json:"email_prefix"`
|
EmailPrefix string `json:"email_prefix"`
|
||||||
EmailNotificationsEnabled bool `json:"email_notifications_enabled"`
|
EmailNotificationsEnabled bool `json:"email_notifications_enabled"`
|
||||||
|
AvatarObjectID *string `json:"avatar_object_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +335,83 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
human, err := h.humanSvc.GetByID(r.Context(), humanId)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to get human by id", "error", err, "humanId", humanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.humanSvc.DeleteAvatar(r.Context(), humanId)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to delete avatar from human", "error", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if human.AvatarObjectID != nil {
|
||||||
|
err = h.depotSvc.Delete(r.Context(), utils.OptionalString(human.AvatarObjectID))
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to delete object", "error", err, "objectID", human.AvatarObjectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5MB limit = 5 * 1024 * 1024 bytes
|
||||||
|
const maxBodySize = 5 << 20
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||||
|
|
||||||
|
object, err := h.depotSvc.CreateFromReader(r.Context(), depot.CreateFromReaderInput{
|
||||||
|
Prefix: "avatars",
|
||||||
|
Name: fmt.Sprintf("%s-avatar", humanId),
|
||||||
|
ContentType: r.Header.Get("Content-Type"),
|
||||||
|
}, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
var maxErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxErr) {
|
||||||
|
http.Error(w, "avatar file too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flog.Error("failed to upload avatar with depo", "error", err, "humanId", humanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.humanSvc.UpdateAvatar(r.Context(), humanId, object.ID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to update human avatar", "error", err, "humanId", humanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
// best effort
|
||||||
|
err = h.depotSvc.Delete(r.Context(), object.ID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("best-effort delete of object failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Network Handlers
|
// Network Handlers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -717,8 +795,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadParticleMedia returns a fresh signed URL for media/file particles.
|
// GetObjectDownloadUrl returns a fresh signed URL for media/file particles.
|
||||||
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetObjectDownloadUrl(w http.ResponseWriter, r *http.Request) {
|
||||||
_, ok := middleware.EmailFromContext(r.Context())
|
_, ok := middleware.EmailFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
@@ -1007,6 +1085,7 @@ func humanToDTO(h *human.Human) Human {
|
|||||||
Email: h.Email,
|
Email: h.Email,
|
||||||
EmailPrefix: h.EmailPrefix,
|
EmailPrefix: h.EmailPrefix,
|
||||||
EmailNotificationsEnabled: h.EmailNotificationsEnabled,
|
EmailNotificationsEnabled: h.EmailNotificationsEnabled,
|
||||||
|
AvatarObjectID: h.AvatarObjectID,
|
||||||
CreatedAt: h.CreatedAt,
|
CreatedAt: h.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type Human struct {
|
|||||||
Email string
|
Email string
|
||||||
EmailPrefix string
|
EmailPrefix string
|
||||||
EmailNotificationsEnabled bool
|
EmailNotificationsEnabled bool
|
||||||
|
AvatarObjectID *string
|
||||||
LastEmailNotificationSentAt *time.Time
|
LastEmailNotificationSentAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type repository interface {
|
|||||||
listAll(ctx context.Context) ([]*Human, error)
|
listAll(ctx context.Context) ([]*Human, error)
|
||||||
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||||
|
updateAvatarObjectID(ctx context.Context, id string, objectID *string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type repositoryImpl struct {
|
type repositoryImpl struct {
|
||||||
@@ -50,9 +51,9 @@ func newRepository(pool *pgxpool.Pool) repository {
|
|||||||
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
||||||
var h Human
|
var h Human
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE email = $1`,
|
||||||
email,
|
email,
|
||||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
@@ -66,9 +67,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
|
|||||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
||||||
var h Human
|
var h Human
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE id = $1`,
|
||||||
id,
|
id,
|
||||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
@@ -113,7 +114,7 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
|
|||||||
|
|
||||||
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -123,7 +124,7 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
|||||||
var humans []*Human
|
var humans []*Human
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var h Human
|
var h Human
|
||||||
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt); err != nil {
|
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
h.EmailPrefix = emailPrefix(h.Email)
|
h.EmailPrefix = emailPrefix(h.Email)
|
||||||
@@ -159,3 +160,17 @@ func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context,
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) updateAvatarObjectID(ctx context.Context, id string, objectID *string) error {
|
||||||
|
result, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE humans SET avatar_object_id = $2 WHERE id = $1`,
|
||||||
|
id, objectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return errNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import (
|
|||||||
|
|
||||||
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
||||||
|
|
||||||
var ErrNotFound = errors.New("human not found")
|
var (
|
||||||
|
ErrNotFound = errors.New("human not found")
|
||||||
|
ErrInvalidParam = errors.New("invalid param")
|
||||||
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
||||||
@@ -22,6 +25,8 @@ type Service interface {
|
|||||||
ListAll(ctx context.Context) ([]*Human, error)
|
ListAll(ctx context.Context) ([]*Human, error)
|
||||||
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||||
|
UpdateAvatar(ctx context.Context, id string, objectID string) error
|
||||||
|
DeleteAvatar(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceImpl struct {
|
type serviceImpl struct {
|
||||||
@@ -88,3 +93,22 @@ func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) UpdateAvatar(ctx context.Context, id string, objectID string) error {
|
||||||
|
if objectID == "" {
|
||||||
|
return ErrInvalidParam
|
||||||
|
}
|
||||||
|
err := s.repo.updateAvatarObjectID(ctx, id, utils.CreateOptionalString(objectID))
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) DeleteAvatar(ctx context.Context, id string) error {
|
||||||
|
err := s.repo.updateAvatarObjectID(ctx, id, nil)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,4 +56,19 @@ func TestHumanService(t *testing.T) {
|
|||||||
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
|
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
|
||||||
assert.Equal(t, "another@example.com", anotherHuman.Email)
|
assert.Equal(t, "another@example.com", anotherHuman.Email)
|
||||||
assert.Equal(t, "another", anotherHuman.EmailPrefix)
|
assert.Equal(t, "another", anotherHuman.EmailPrefix)
|
||||||
|
|
||||||
|
// Test avatar handling
|
||||||
|
objectID := "obj_xxx"
|
||||||
|
err = svc.UpdateAvatar(ctx, anotherHuman.ID, objectID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, objectID, *anotherHuman.AvatarObjectID)
|
||||||
|
|
||||||
|
err = svc.DeleteAvatar(ctx, anotherHuman.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, anotherHuman.AvatarObjectID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ spec:
|
|||||||
name: shared-secrets
|
name: shared-secrets
|
||||||
key: STRIPE_WEBHOOK_SECRET
|
key: STRIPE_WEBHOOK_SECRET
|
||||||
- name: "STRIPE_PRICE_PRO_MONTHLY"
|
- name: "STRIPE_PRICE_PRO_MONTHLY"
|
||||||
value: "price_1TMBElJu6RWBXAm2pPthUoh6"
|
value: "price_1ToweOJu6RWBXAm2w2wTo8VL"
|
||||||
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
||||||
value: "price_1TMBElJu6RWBXAm2TOpzdXk5"
|
value: "price_1TowexJu6RWBXAm2G9M0WJBK"
|
||||||
- name: "BILLING_SUCCESS_URL"
|
- name: "BILLING_SUCCESS_URL"
|
||||||
value: "llink://billing/success"
|
value: "llink://billing/success"
|
||||||
- name: "BILLING_CANCEL_URL"
|
- name: "BILLING_CANCEL_URL"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE humans
|
||||||
|
DROP COLUMN IF EXISTS avatar_object_id;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE humans
|
||||||
|
ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+17
-1
@@ -1,8 +1,24 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html class="dark">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>llink</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Flowy.llink",
|
"name": "Flowy.llink",
|
||||||
"productName": "Flowy.llink",
|
"productName": "Flowy.llink",
|
||||||
"version": "1.5.2",
|
"version": "1.9.0",
|
||||||
"description": "Flowy.llink is a video messaging app for teams",
|
"description": "Flowy.llink is a video messaging app for teams",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
"@livekit/components-react": "^2.9.20",
|
"@livekit/components-react": "^2.9.20",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"@milkdown/crepe": "^7.21.1",
|
"@milkdown/crepe": "^7.21.1",
|
||||||
|
"@milkdown/kit": "7.21.1",
|
||||||
"@sentry/electron": "^7.11.0",
|
"@sentry/electron": "^7.11.0",
|
||||||
"@sentry/react": "^10.54.0",
|
"@sentry/react": "^10.54.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
|||||||
@@ -23,12 +23,18 @@ import {
|
|||||||
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
|
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
|
||||||
import { platform } from '@/lib/platform';
|
import { platform } from '@/lib/platform';
|
||||||
import { InAppAutoplayCard } from '@/components/in-app-autoplay-card';
|
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 queryClient = createQueryClient();
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const status = useAuthStore((s) => s.status);
|
const status = useAuthStore((s) => s.status);
|
||||||
const restoreSession = useAuthStore((s) => s.restoreSession);
|
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||||
|
const hasCompletedOnboarding = useOnboardingStore(
|
||||||
|
(s) => s.hasCompletedOnboarding,
|
||||||
|
);
|
||||||
|
const markOnboardingComplete = useOnboardingStore((s) => s.markComplete);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restoreSession();
|
restoreSession();
|
||||||
@@ -46,6 +52,10 @@ const App = () => {
|
|||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasCompletedOnboarding) {
|
||||||
|
return <OnboardingOverlay onComplete={markOnboardingComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PusherProvider>
|
<PusherProvider>
|
||||||
<AuthenticatedApp />
|
<AuthenticatedApp />
|
||||||
|
|||||||
@@ -42,16 +42,12 @@ class ApiClient {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetch(
|
private async send(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
init: { headers?: Record<string, string>; body?: BodyInit } = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = { ...init.headers };
|
||||||
|
|
||||||
if (body) {
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = this.config.getToken();
|
const token = this.config.getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -61,7 +57,7 @@ class ApiClient {
|
|||||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: init.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -77,6 +73,17 @@ class ApiClient {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetch(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<Response> {
|
||||||
|
return this.send(method, path, {
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
schema: z.ZodType<T>,
|
schema: z.ZodType<T>,
|
||||||
method: string,
|
method: string,
|
||||||
@@ -137,6 +144,25 @@ class ApiClient {
|
|||||||
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Avatar ---
|
||||||
|
|
||||||
|
async updateAvatar(blob: Blob): Promise<void> {
|
||||||
|
await this.send('PUT', '/humans/me/avatar', {
|
||||||
|
headers: { 'Content-Type': blob.type || 'image/jpeg' },
|
||||||
|
body: blob,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAvatar(): Promise<void> {
|
||||||
|
await this.requestVoid('DELETE', '/humans/me/avatar');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvatarDownloadUrl(objectId: string): Promise<string> {
|
||||||
|
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Depot ---
|
// --- Depot ---
|
||||||
|
|
||||||
async prepareUpload(data: PrepareUploadRequest) {
|
async prepareUpload(data: PrepareUploadRequest) {
|
||||||
|
|||||||
+42
-12
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
email_prefix: z.string(),
|
email_prefix: z.string(),
|
||||||
email_notifications_enabled: z.boolean(),
|
email_notifications_enabled: z.boolean(),
|
||||||
|
avatar_object_id: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Human = z.infer<typeof HumanSchema>;
|
export type Human = z.infer<typeof HumanSchema>;
|
||||||
@@ -145,14 +146,21 @@ export const TextPropertiesSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
||||||
|
|
||||||
export const QuestPropertiesSchema = z.object({
|
export const ChecklistItemSchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
done: z.boolean(),
|
||||||
|
});
|
||||||
|
export type ChecklistItem = z.infer<typeof ChecklistItemSchema>;
|
||||||
|
|
||||||
|
export const TaskPropertiesSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
notes: z.string().optional(),
|
||||||
status: z.string().optional(),
|
checklist: z.array(ChecklistItemSchema).optional(),
|
||||||
// humanId
|
// humanId
|
||||||
assigned_to: z.string().optional(),
|
assigned_to: z.string().optional(),
|
||||||
|
done: z.boolean(),
|
||||||
});
|
});
|
||||||
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
|
export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
|
||||||
|
|
||||||
export const PaperPropertiesSchema = z.object({
|
export const PaperPropertiesSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -193,7 +201,7 @@ export interface ParticlePropertiesMap {
|
|||||||
media: MediaProperties;
|
media: MediaProperties;
|
||||||
file: FileProperties;
|
file: FileProperties;
|
||||||
text: TextProperties;
|
text: TextProperties;
|
||||||
quest: QuestProperties;
|
task: TaskProperties;
|
||||||
paper: PaperProperties;
|
paper: PaperProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +218,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal('stream'),
|
type: z.literal('stream'),
|
||||||
properties: StreamPropertiesSchema,
|
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. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||||
// e.g. ["network:xywx"] - visible to everyone in the network
|
// e.g. ["network:xywx"] - visible to everyone in the network
|
||||||
visible_to: z.array(z.string()),
|
visible_to: z.array(z.string()),
|
||||||
@@ -220,7 +231,6 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
last_child_created_at: z.coerce.date().optional(),
|
last_child_created_at: z.coerce.date().optional(),
|
||||||
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
||||||
huddle_active_participants: z.array(z.string()).optional(),
|
huddle_active_participants: z.array(z.string()).optional(),
|
||||||
status: z.enum(['open', 'closed']).optional(),
|
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal('folder'),
|
type: z.literal('folder'),
|
||||||
@@ -228,6 +238,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||||
// e.g. ["network:123"] - visible to everyone in the network
|
// e.g. ["network:123"] - visible to everyone in the network
|
||||||
visible_to: z.array(z.string()),
|
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({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal('media'),
|
type: z.literal('media'),
|
||||||
@@ -247,8 +260,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal('quest'),
|
type: z.literal('task'),
|
||||||
properties: QuestPropertiesSchema,
|
properties: TaskPropertiesSchema,
|
||||||
|
reactions: ReactionsSchema,
|
||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
@@ -258,17 +272,28 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type Particle = z.infer<typeof ParticleSchema>;
|
// Placeholder for docs whose `type` this client version doesn't recognize
|
||||||
|
// (e.g. a newer client wrote a particle type we don't ship yet). The converter
|
||||||
|
// maps them here instead of throwing, so they degrade to an "unsupported"
|
||||||
|
// view rather than breaking the whole subscription.
|
||||||
|
export const UnknownParticleSchema = ParticleBaseSchema.extend({
|
||||||
|
type: z.literal('unknown'),
|
||||||
|
raw_type: z.string(),
|
||||||
|
});
|
||||||
|
export type UnknownParticle = z.infer<typeof UnknownParticleSchema>;
|
||||||
|
|
||||||
export type ParticleType = Particle['type'];
|
export type Particle = z.infer<typeof ParticleSchema> | UnknownParticle;
|
||||||
|
|
||||||
|
/** Particle types this client can read and write — excludes 'unknown'. */
|
||||||
|
export type ParticleType = z.infer<typeof ParticleSchema>['type'];
|
||||||
|
|
||||||
/** Container types can have children subcollections */
|
/** Container types can have children subcollections */
|
||||||
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set([
|
export const CONTAINER_TYPES: ReadonlySet<Particle['type']> = new Set([
|
||||||
'stream',
|
'stream',
|
||||||
'folder',
|
'folder',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isContainerType(type: ParticleType): boolean {
|
export function isContainerType(type: Particle['type']): boolean {
|
||||||
return CONTAINER_TYPES.has(type);
|
return CONTAINER_TYPES.has(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +302,11 @@ export function isParticleDeleted(particle: Particle): boolean {
|
|||||||
return 'deleted_at' in particle && particle.deleted_at != null;
|
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 ---
|
// --- LiveKit types ---
|
||||||
|
|
||||||
export const GetLivekitTokenResponseSchema = z.object({
|
export const GetLivekitTokenResponseSchema = z.object({
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ export function ComposingIndicator({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={u.humanId}
|
key={u.humanId}
|
||||||
className="flex rotate-180 items-center gap-1.5 rounded-full bg-white/10 px-2 py-1 backdrop-blur-sm"
|
className="bg-card/70 border-border flex rotate-180 items-center gap-1.5 rounded-full border px-2 py-1 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<span className="flex gap-0.5">
|
<span className="flex gap-0.5">
|
||||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:0ms]" />
|
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:0ms]" />
|
||||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:150ms]" />
|
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:150ms]" />
|
||||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" />
|
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:300ms]" />
|
||||||
</span>
|
</span>
|
||||||
<span className="whitespace-nowrap text-[10px] text-white/50">
|
<span className="text-muted-foreground whitespace-nowrap text-[10px]">
|
||||||
{displayName} {modeLabel}
|
{displayName} {modeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,23 +38,23 @@ export function ConfirmDestructiveOverlay({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100]">
|
<div className="fixed inset-0 z-[100]">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<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="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="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc)"
|
title="Close (or press Esc)"
|
||||||
className="text-xs text-white/30"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-white/60">{description}</div>
|
<div className="text-muted-foreground text-sm">{description}</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-end gap-2">
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
|
|
||||||
|
interface HumanAvatarProps extends Omit<
|
||||||
|
React.ComponentProps<typeof Avatar>,
|
||||||
|
'children'
|
||||||
|
> {
|
||||||
|
/** Object id of the human's profile picture, if any. */
|
||||||
|
avatarObjectId?: string | null;
|
||||||
|
/** Initials rendered while loading or when no picture is set. */
|
||||||
|
initials: string;
|
||||||
|
/** Extra classes for the initials fallback. */
|
||||||
|
fallbackClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a human's avatar: their profile picture when set (resolved to a
|
||||||
|
* signed URL), otherwise their initials. The fallback also shows while the
|
||||||
|
* image loads or if it fails, so this is a drop-in for the initials-only
|
||||||
|
* <Avatar> usages throughout the app.
|
||||||
|
*/
|
||||||
|
export function HumanAvatar({
|
||||||
|
avatarObjectId,
|
||||||
|
initials,
|
||||||
|
fallbackClassName,
|
||||||
|
...props
|
||||||
|
}: HumanAvatarProps) {
|
||||||
|
const url = useAvatarUrl(avatarObjectId);
|
||||||
|
return (
|
||||||
|
<Avatar {...props}>
|
||||||
|
<AvatarImage src={url} alt={initials} />
|
||||||
|
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ import { Fragment } from 'react';
|
|||||||
import { Kbd } from '@/components/ui/kbd';
|
import { Kbd } from '@/components/ui/kbd';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/** Dark-overlay chip restyle of the design-system Kbd, kept in one place. */
|
/** 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. */
|
||||||
const chipClass =
|
const chipClass =
|
||||||
'rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
|
'rounded bg-current/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
|
||||||
|
|
||||||
interface KeyHintProps {
|
interface KeyHintProps {
|
||||||
/** Key chip(s): "Esc" or ["Esc", "Q"]. */
|
/** Key chip(s): "Esc" or ["Esc", "Q"]. */
|
||||||
@@ -68,7 +69,7 @@ export function KeyHint({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
title={title}
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer rounded transition-colors hover:text-white/80',
|
'cursor-pointer rounded transition-opacity hover:opacity-80',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -48,19 +48,19 @@ export function KeybindingsOverlay({
|
|||||||
<div className="fixed inset-0 z-[100]">
|
<div className="fixed inset-0 z-[100]">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<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="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="mb-5 flex items-center justify-between">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys={['Esc', '?']}
|
keys={['Esc', '?']}
|
||||||
separator="or"
|
separator="or"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc / ?)"
|
title="Close (or press Esc / ?)"
|
||||||
className="text-xs text-white/30"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -68,21 +68,19 @@ export function KeybindingsOverlay({
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<section key={group.label}>
|
<section key={group.label}>
|
||||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
||||||
{group.label}
|
{group.label}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{group.bindings.map((binding) => (
|
{group.bindings.map((binding) => (
|
||||||
<li
|
<li
|
||||||
key={binding.description}
|
key={binding.description}
|
||||||
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0"
|
className="border-border flex items-center justify-between border-b pb-1 last:border-0 last:pb-0"
|
||||||
>
|
>
|
||||||
<span className="text-sm text-white/70">
|
<span className="text-sm">{binding.description}</span>
|
||||||
{binding.description}
|
|
||||||
</span>
|
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys={binding.keys}
|
keys={binding.keys}
|
||||||
className="flex items-center gap-1 text-white/60"
|
className="text-muted-foreground flex items-center gap-1"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Copy, ExternalLink, Globe } from 'lucide-react';
|
import { Copy, ExternalLink, Globe } from 'lucide-react';
|
||||||
import type { LinkMetadata } from '@/lib/link-metadata';
|
import { domainFromUrl, type LinkMetadata } from '@/lib/link-metadata';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { platform } from '@/lib/platform';
|
import { platform } from '@/lib/platform';
|
||||||
@@ -22,7 +22,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md"
|
className="bg-card text-card-foreground border-border max-w-sm overflow-hidden rounded-2xl border"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleOpen(e);
|
handleOpen(e);
|
||||||
@@ -39,7 +39,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1 p-3">
|
<div className="flex flex-col gap-1 p-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-white/50">
|
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||||
{metadata.favicon ? (
|
{metadata.favicon ? (
|
||||||
<img
|
<img
|
||||||
src={metadata.favicon}
|
src={metadata.favicon}
|
||||||
@@ -57,12 +57,12 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
<span className="truncate">{metadata.domain}</span>
|
<span className="truncate">{metadata.domain}</span>
|
||||||
</div>
|
</div>
|
||||||
{metadata.title && (
|
{metadata.title && (
|
||||||
<p className="truncate text-sm font-semibold leading-snug text-white">
|
<p className="truncate text-sm font-semibold leading-snug">
|
||||||
{metadata.title}
|
{metadata.title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{metadata.description && (
|
{metadata.description && (
|
||||||
<p className="line-clamp-2 text-xs leading-relaxed text-white/70">
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
{metadata.description}
|
{metadata.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -71,7 +71,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
className="text-muted-foreground"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
>
|
>
|
||||||
<ExternalLink data-icon="inline-start" />
|
<ExternalLink data-icon="inline-start" />
|
||||||
@@ -80,7 +80,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
className="text-muted-foreground"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
<Copy data-icon="inline-start" />
|
<Copy data-icon="inline-start" />
|
||||||
@@ -93,14 +93,30 @@ 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() {
|
export function LinkPreviewCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
|
<div className="bg-card border-border max-w-sm overflow-hidden rounded-2xl border">
|
||||||
<Skeleton className="h-32 w-full rounded-none bg-white/5" />
|
<Skeleton className="h-32 w-full rounded-none" />
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<div className="flex flex-col gap-2 p-3">
|
||||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
<Skeleton className="h-3 w-24" />
|
||||||
<Skeleton className="h-4 w-48 bg-white/10" />
|
<Skeleton className="h-4 w-48" />
|
||||||
<Skeleton className="h-3 w-full bg-white/10" />
|
<Skeleton className="h-3 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ export function ScreenSourcePicker({
|
|||||||
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
<div className="bg-scrim/80 absolute inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
|
<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="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
<div className="border-border flex items-center justify-between border-b px-5 py-4">
|
||||||
<h2 className="text-base font-medium text-zinc-100">{title}</h2>
|
<h2 className="text-base font-medium">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-zinc-400 hover:text-zinc-200"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -48,7 +48,7 @@ export function ScreenSourcePicker({
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-center text-sm text-zinc-400">
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
Loading sources...
|
Loading sources...
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -73,17 +73,17 @@ export function ScreenSourcePicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3">
|
<div className="border-border flex justify-end gap-2 border-t px-5 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
|
className="hover:bg-accent text-muted-foreground rounded-md px-4 py-2 text-sm"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={!selectedId}
|
disabled={!selectedId}
|
||||||
onClick={() => selectedId && onSelect(selectedId)}
|
onClick={() => selectedId && onSelect(selectedId)}
|
||||||
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"
|
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
@@ -106,7 +106,7 @@ function SourceSection({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">
|
<h3 className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
@@ -116,8 +116,8 @@ function SourceSection({
|
|||||||
onClick={() => onSelect(source.id)}
|
onClick={() => onSelect(source.id)}
|
||||||
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
||||||
selectedId === source.id
|
selectedId === source.id
|
||||||
? 'border-blue-500 bg-zinc-800'
|
? 'border-primary bg-accent'
|
||||||
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
|
: 'border-transparent bg-muted hover:border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -125,7 +125,7 @@ function SourceSection({
|
|||||||
alt={source.name}
|
alt={source.name}
|
||||||
className="aspect-video w-full object-cover"
|
className="aspect-video w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">
|
<p className="text-muted-foreground truncate px-2 py-1.5 text-xs">
|
||||||
{source.name}
|
{source.name}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
'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',
|
'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',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function ScrollArea({
|
|||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-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"
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!w-full"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function Slider({
|
|||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
data-slot="slider-thumb"
|
data-slot="slider-thumb"
|
||||||
key={index}
|
key={index}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
OctagonXIcon,
|
OctagonXIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const isDark = useThemeStore((s) => s.isDark);
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme="dark"
|
theme={isDark ? 'dark' : 'light'}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
icons={{
|
icons={{
|
||||||
success: <CircleCheckIcon className="size-4" />,
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -21,7 +21,7 @@ export function WindowControls() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => platform.window.minimize()}
|
onClick={() => platform.window.minimize()}
|
||||||
aria-label="Minimize"
|
aria-label="Minimize"
|
||||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
className="text-muted-foreground rounded"
|
||||||
>
|
>
|
||||||
<Minus />
|
<Minus />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -34,7 +34,7 @@ export function WindowControls() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => platform.window.maximize()}
|
onClick={() => platform.window.maximize()}
|
||||||
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
||||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
className="text-muted-foreground rounded"
|
||||||
>
|
>
|
||||||
{isMaximized ? <Copy /> : <Square />}
|
{isMaximized ? <Copy /> : <Square />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -47,7 +47,7 @@ export function WindowControls() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => platform.window.close()}
|
onClick={() => platform.window.close()}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50"
|
className="hover:text-destructive text-muted-foreground rounded"
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function AttachmentLightbox({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogPrimitive.Portal>
|
<DialogPrimitive.Portal>
|
||||||
<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.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.Content
|
<DialogPrimitive.Content
|
||||||
aria-describedby={undefined}
|
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"
|
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,6 +4,7 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { LinkPreviewEntry } from '@/hooks/use-link-metadata';
|
import type { LinkPreviewEntry } from '@/hooks/use-link-metadata';
|
||||||
|
import { domainFromUrl } from '@/lib/link-metadata';
|
||||||
import {
|
import {
|
||||||
AttachmentLightbox,
|
AttachmentLightbox,
|
||||||
getAttachmentHandler,
|
getAttachmentHandler,
|
||||||
@@ -61,7 +62,7 @@ function AttachmentThumbnail({
|
|||||||
tabIndex={previewable ? 0 : undefined}
|
tabIndex={previewable ? 0 : undefined}
|
||||||
onClick={previewable && onPreview ? onPreview : undefined}
|
onClick={previewable && onPreview ? onPreview : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10',
|
'bg-muted group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg',
|
||||||
previewable && 'cursor-pointer',
|
previewable && 'cursor-pointer',
|
||||||
isError && 'ring-1 ring-red-400/50',
|
isError && 'ring-1 ring-red-400/50',
|
||||||
)}
|
)}
|
||||||
@@ -74,19 +75,19 @@ function AttachmentThumbnail({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-0.5 px-1">
|
<div className="flex flex-col items-center gap-0.5 px-1">
|
||||||
<FileIcon className="size-5 text-white/60" />
|
<FileIcon className="text-muted-foreground size-5" />
|
||||||
<span className="max-w-full truncate text-[9px] text-white/50">
|
<span className="text-muted-foreground max-w-full truncate text-[9px]">
|
||||||
{attachment.file.name}
|
{attachment.file.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-white/40">
|
<span className="text-muted-foreground text-[9px]">
|
||||||
{formatFileSize(attachment.file.size)}
|
{formatFileSize(attachment.file.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
<div className="bg-scrim/50 absolute inset-0 flex items-center justify-center">
|
||||||
<Loader2 className="size-4 animate-spin text-white/70" />
|
<Loader2 className="size-4 animate-spin text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ function AttachmentThumbnail({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove();
|
onRemove();
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -107,25 +108,26 @@ function AttachmentThumbnail({
|
|||||||
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||||
if (entry.isLoading) {
|
if (entry.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
|
<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 bg-white/10" />
|
<Skeleton className="h-2 w-16" />
|
||||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
<Skeleton className="h-3 w-24" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entry.metadata) return null;
|
// Metadata fetch can fail; fall back to the bare URL so the link stays usable.
|
||||||
|
|
||||||
const { metadata } = entry;
|
const { metadata } = entry;
|
||||||
|
const domain = metadata?.domain ?? domainFromUrl(entry.url);
|
||||||
|
const title = metadata?.title ?? entry.url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => platform.link.openExternal(metadata.url)}
|
onClick={() => platform.link.openExternal(metadata?.url ?? entry.url)}
|
||||||
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 text-[10px] text-white/40">
|
<div className="text-muted-foreground flex items-center gap-1 text-[10px]">
|
||||||
{metadata.favicon ? (
|
{metadata?.favicon ? (
|
||||||
<img
|
<img
|
||||||
src={metadata.favicon}
|
src={metadata.favicon}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -137,11 +139,11 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
|||||||
) : (
|
) : (
|
||||||
<Globe className="size-3" />
|
<Globe className="size-3" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{metadata.domain}</span>
|
<span className="truncate">{domain}</span>
|
||||||
</div>
|
</div>
|
||||||
{metadata.title && (
|
{title && (
|
||||||
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
|
<p className="text-foreground line-clamp-2 text-[11px] font-medium leading-tight">
|
||||||
{metadata.title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -195,7 +197,7 @@ export function AttachmentStrip({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAddClick();
|
onAddClick();
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import { RecordingOverlay } from '@/features/compose/recording-overlay';
|
|||||||
import { ScreenSourcePicker } from '@/components/screen-source-picker';
|
import { ScreenSourcePicker } from '@/components/screen-source-picker';
|
||||||
import { KeyHint } from '@/components/key-hint';
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { TextComposeStep } from '@/features/compose/text-compose-step';
|
import { TextComposeStep } from '@/features/compose/text-compose-step';
|
||||||
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
|
import { TaskComposeStep } from '@/features/compose/task-compose-step';
|
||||||
|
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
|
||||||
|
import type { TaskProperties } from '@/api/types';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||||
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
||||||
@@ -27,7 +29,11 @@ import { useMediaDevices } from '@/hooks/use-media-devices';
|
|||||||
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
|
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
|
||||||
import { useFileInput } from '@/hooks/use-file-input';
|
import { useFileInput } from '@/hooks/use-file-input';
|
||||||
import { createImageThumbnail } from '@/lib/image-thumbnail';
|
import { createImageThumbnail } from '@/lib/image-thumbnail';
|
||||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from '@/lib/constants';
|
import {
|
||||||
|
HOLD_THRESHOLD_MS,
|
||||||
|
MAX_ATTACHMENT_SIZE_BYTES,
|
||||||
|
MAX_ATTACHMENTS,
|
||||||
|
} from '@/lib/constants';
|
||||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||||
@@ -40,11 +46,14 @@ export type ComposeStep =
|
|||||||
| 'recording'
|
| 'recording'
|
||||||
| 'reviewing'
|
| 'reviewing'
|
||||||
| 'typing'
|
| 'typing'
|
||||||
|
| 'task'
|
||||||
| 'configuring'
|
| 'configuring'
|
||||||
| 'submitting';
|
| 'submitting';
|
||||||
|
|
||||||
type RecordingSource = 'media' | 'screen';
|
type RecordingSource = 'media' | 'screen';
|
||||||
|
|
||||||
|
type PendingArtifact = { type: 'task'; properties: TaskProperties };
|
||||||
|
|
||||||
interface ComposeOverlayProps {
|
interface ComposeOverlayProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
// Optional target path for reply mode. If not provided, compose creates a new stream.
|
// Optional target path for reply mode. If not provided, compose creates a new stream.
|
||||||
@@ -52,12 +61,8 @@ interface ComposeOverlayProps {
|
|||||||
onActiveChange?: (active: boolean) => void;
|
onActiveChange?: (active: boolean) => void;
|
||||||
onStepChange?: (step: ComposeStep) => void;
|
onStepChange?: (step: ComposeStep) => void;
|
||||||
onParticleCreated?: (particleId: string) => 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
|
* Self-contained compose overlay. Each consumer renders its own instance
|
||||||
* with props that determine the mode (new stream vs. reply).
|
* with props that determine the mode (new stream vs. reply).
|
||||||
@@ -68,7 +73,6 @@ export function ComposeOverlay({
|
|||||||
onActiveChange,
|
onActiveChange,
|
||||||
onStepChange,
|
onStepChange,
|
||||||
onParticleCreated,
|
onParticleCreated,
|
||||||
disabled,
|
|
||||||
}: ComposeOverlayProps) {
|
}: ComposeOverlayProps) {
|
||||||
const [step, setStep] = useState<ComposeStep>('idle');
|
const [step, setStep] = useState<ComposeStep>('idle');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -98,14 +102,14 @@ export function ComposeOverlay({
|
|||||||
// Latest props/state for synchronous reads in keyboard handlers.
|
// Latest props/state for synchronous reads in keyboard handlers.
|
||||||
const stepRef = useRef(step);
|
const stepRef = useRef(step);
|
||||||
const recordStartRef = useRef(0);
|
const recordStartRef = useRef(0);
|
||||||
const disabledRef = useRef(disabled);
|
|
||||||
const quotaExhaustedRef = useRef(quotaExhausted);
|
const quotaExhaustedRef = useRef(quotaExhausted);
|
||||||
const recordingSourceRef = useRef(recordingSource);
|
const recordingSourceRef = useRef(recordingSource);
|
||||||
|
|
||||||
|
const pendingArtifactRef = useRef<PendingArtifact | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
disabledRef.current = disabled;
|
|
||||||
quotaExhaustedRef.current = quotaExhausted;
|
quotaExhaustedRef.current = quotaExhausted;
|
||||||
recordingSourceRef.current = recordingSource;
|
recordingSourceRef.current = recordingSource;
|
||||||
}, [disabled, quotaExhausted, recordingSource]);
|
}, [quotaExhausted, recordingSource]);
|
||||||
|
|
||||||
const setStepSync = useCallback((next: ComposeStep) => {
|
const setStepSync = useCallback((next: ComposeStep) => {
|
||||||
stepRef.current = next;
|
stepRef.current = next;
|
||||||
@@ -142,6 +146,7 @@ export function ComposeOverlay({
|
|||||||
setReviewDurationMs(0);
|
setReviewDurationMs(0);
|
||||||
setReviewMimeType(null);
|
setReviewMimeType(null);
|
||||||
setRecordingSource('media');
|
setRecordingSource('media');
|
||||||
|
pendingArtifactRef.current = null;
|
||||||
setAttachments((prev) => {
|
setAttachments((prev) => {
|
||||||
revokeAttachmentThumbnails(prev);
|
revokeAttachmentThumbnails(prev);
|
||||||
return [];
|
return [];
|
||||||
@@ -330,7 +335,15 @@ export function ComposeOverlay({
|
|||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
let particleId: undefined | string;
|
let particleId: undefined | string;
|
||||||
if (textContent.trim()) {
|
const pendingArtifact = pendingArtifactRef.current;
|
||||||
|
if (pendingArtifact) {
|
||||||
|
particleId = await createParticle.mutateAsync({
|
||||||
|
path,
|
||||||
|
type: pendingArtifact.type,
|
||||||
|
properties: pendingArtifact.properties,
|
||||||
|
createdByHumanId: userId,
|
||||||
|
});
|
||||||
|
} else if (textContent.trim()) {
|
||||||
particleId = await createParticle.mutateAsync({
|
particleId = await createParticle.mutateAsync({
|
||||||
path,
|
path,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -454,14 +467,10 @@ export function ComposeOverlay({
|
|||||||
// --- Compose intent handlers ---
|
// --- Compose intent handlers ---
|
||||||
// Single source of truth for the step transitions triggered by the user.
|
// Single source of truth for the step transitions triggered by the user.
|
||||||
// Both the keyboard handler and the intent store dispatch into these so
|
// Both the keyboard handler and the intent store dispatch into these so
|
||||||
// guards (disabled, quota) and screen-vs-media branching live in one place.
|
// guards (quota) and screen-vs-media branching live in one place.
|
||||||
|
|
||||||
const guardIdle = useCallback((): boolean => {
|
const guardIdle = useCallback((): boolean => {
|
||||||
if (stepRef.current !== 'idle') return false;
|
if (stepRef.current !== 'idle') return false;
|
||||||
if (disabledRef.current) {
|
|
||||||
toast.info('This stream is closed');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (quotaExhaustedRef.current) {
|
if (quotaExhaustedRef.current) {
|
||||||
toast.info(
|
toast.info(
|
||||||
'Daily message limit reached. Upgrade to Pro to keep sending.',
|
'Daily message limit reached. Upgrade to Pro to keep sending.',
|
||||||
@@ -484,6 +493,26 @@ export function ComposeOverlay({
|
|||||||
setStepSync('typing');
|
setStepSync('typing');
|
||||||
}, [guardIdle, setStepSync]);
|
}, [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(() => {
|
const handleStopIntent = useCallback(() => {
|
||||||
if (stepRef.current !== 'recording') return;
|
if (stepRef.current !== 'recording') return;
|
||||||
if (recordingSourceRef.current === 'screen') {
|
if (recordingSourceRef.current === 'screen') {
|
||||||
@@ -537,6 +566,9 @@ export function ComposeOverlay({
|
|||||||
case 'text':
|
case 'text':
|
||||||
handleTextIntent();
|
handleTextIntent();
|
||||||
break;
|
break;
|
||||||
|
case 'task':
|
||||||
|
handleTaskIntent();
|
||||||
|
break;
|
||||||
case 'stop':
|
case 'stop':
|
||||||
handleStopIntent();
|
handleStopIntent();
|
||||||
break;
|
break;
|
||||||
@@ -552,6 +584,7 @@ export function ComposeOverlay({
|
|||||||
}, [
|
}, [
|
||||||
handleRecordIntent,
|
handleRecordIntent,
|
||||||
handleTextIntent,
|
handleTextIntent,
|
||||||
|
handleTaskIntent,
|
||||||
handleStopIntent,
|
handleStopIntent,
|
||||||
handleCancelIntent,
|
handleCancelIntent,
|
||||||
handleSendIntent,
|
handleSendIntent,
|
||||||
@@ -564,13 +597,24 @@ export function ComposeOverlay({
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const currentStep = stepRef.current;
|
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 (
|
if (
|
||||||
currentStep === 'typing' ||
|
currentStep === 'typing' ||
|
||||||
|
currentStep === 'task' ||
|
||||||
currentStep === 'configuring' ||
|
currentStep === 'configuring' ||
|
||||||
currentStep === 'picking'
|
currentStep === 'picking'
|
||||||
) {
|
) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
cancel();
|
cancel();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -588,17 +632,20 @@ export function ComposeOverlay({
|
|||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 'idle': {
|
case 'idle': {
|
||||||
if (e.key === '`' && !e.repeat) {
|
if (e.key === '`' && !e.repeat) {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleRecordIntent();
|
handleRecordIntent();
|
||||||
} else if (e.key === 's' || e.key === 'S') {
|
} else if (e.key === 's' || e.key === 'S') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
if (!guardIdle()) break;
|
if (!guardIdle()) break;
|
||||||
if (!requireDesktop('Screen recording')) break;
|
if (!requireDesktop('Screen recording')) break;
|
||||||
setRecordingSource('screen');
|
setRecordingSource('screen');
|
||||||
setStepSync('picking');
|
setStepSync('picking');
|
||||||
} else if (e.key === 't' || e.key === 'T') {
|
} else if (e.key === 't' || e.key === 'T') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleTextIntent();
|
handleTextIntent();
|
||||||
|
} else if (e.key === 'd' || e.key === 'D') {
|
||||||
|
consume();
|
||||||
|
handleTaskIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -606,17 +653,17 @@ export function ComposeOverlay({
|
|||||||
case 'recording': {
|
case 'recording': {
|
||||||
if (e.key === '`' && !e.repeat) {
|
if (e.key === '`' && !e.repeat) {
|
||||||
// Second tap stops media recording (toggle mode)
|
// Second tap stops media recording (toggle mode)
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleStopIntent();
|
handleStopIntent();
|
||||||
} else if (
|
} else if (
|
||||||
(e.key === 's' || e.key === 'S') &&
|
(e.key === 's' || e.key === 'S') &&
|
||||||
recordingSourceRef.current === 'screen'
|
recordingSourceRef.current === 'screen'
|
||||||
) {
|
) {
|
||||||
// S stops screen recording when main window is focused
|
// S stops screen recording when main window is focused
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleStopIntent();
|
handleStopIntent();
|
||||||
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleCancelIntent();
|
handleCancelIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -624,10 +671,10 @@ export function ComposeOverlay({
|
|||||||
|
|
||||||
case 'reviewing': {
|
case 'reviewing': {
|
||||||
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleCancelIntent();
|
handleCancelIntent();
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleSendIntent();
|
handleSendIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -654,10 +701,13 @@ export function ComposeOverlay({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
// 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('keyup', handleKeyUp);
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@@ -666,6 +716,7 @@ export function ComposeOverlay({
|
|||||||
guardIdle,
|
guardIdle,
|
||||||
handleRecordIntent,
|
handleRecordIntent,
|
||||||
handleTextIntent,
|
handleTextIntent,
|
||||||
|
handleTaskIntent,
|
||||||
handleStopIntent,
|
handleStopIntent,
|
||||||
handleCancelIntent,
|
handleCancelIntent,
|
||||||
handleSendIntent,
|
handleSendIntent,
|
||||||
@@ -719,7 +770,7 @@ export function ComposeOverlay({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 'recording' && recordingSource === 'screen' && (
|
{step === 'recording' && recordingSource === 'screen' && (
|
||||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
<div className="bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center">
|
||||||
<div className="absolute top-8 z-10">
|
<div className="absolute top-8 z-10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
||||||
@@ -774,16 +825,25 @@ export function ComposeOverlay({
|
|||||||
dropZoneProps={dropZoneProps}
|
dropZoneProps={dropZoneProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{step === 'task' && (
|
||||||
|
<TaskComposeStep
|
||||||
|
networkId={networkId}
|
||||||
|
onCancel={cancel}
|
||||||
|
onSubmit={(properties) =>
|
||||||
|
handleArtifactSubmit({ type: 'task', properties })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!targetPath && step === 'configuring' && (
|
{!targetPath && step === 'configuring' && (
|
||||||
<ConfigureStreamStep
|
<ConfigureContainerStep
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
onCancel={cancel}
|
onCancel={cancel}
|
||||||
onSubmit={handleStreamSubmit}
|
onSubmit={handleStreamSubmit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 'submitting' && (
|
{step === 'submitting' && (
|
||||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
<div className="bg-background/95 absolute inset-0 z-50 flex flex-col items-center justify-center backdrop-blur-sm">
|
||||||
<span className="animate-pulse text-sm text-white/60">
|
<span className="text-muted-foreground animate-pulse text-sm">
|
||||||
Sending...
|
Sending...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
-15
@@ -10,17 +10,20 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
interface ConfigureStreamStepProps {
|
interface ConfigureContainerStepProps {
|
||||||
|
/** Drives labels and hints; the form is identical for both kinds. */
|
||||||
|
kind?: 'stream' | 'folder';
|
||||||
networkId: string | null;
|
networkId: string | null;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (streamName: string, visibleTo: string[]) => void;
|
onSubmit: (name: string, visibleTo: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigureStreamStep({
|
export function ConfigureContainerStep({
|
||||||
|
kind = 'stream',
|
||||||
networkId,
|
networkId,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: ConfigureStreamStepProps) {
|
}: ConfigureContainerStepProps) {
|
||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
const network = networks?.find((n) => n.id === networkId);
|
const network = networks?.find((n) => n.id === networkId);
|
||||||
|
|
||||||
@@ -74,14 +77,15 @@ export function ConfigureStreamStep({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
|
className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
||||||
{/* Stream name */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
|
<Label className="text-muted-foreground mb-1 text-xs">
|
||||||
|
{kind === 'folder' ? 'Folder name' : 'Stream name'}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -90,21 +94,22 @@ export function ConfigureStreamStep({
|
|||||||
setName(e.target.value);
|
setName(e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="Give it a name..."
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Visibility */}
|
{/* Visibility */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
|
<Label className="text-muted-foreground mb-1 text-xs">
|
||||||
<div className="rounded-md border border-white/10">
|
Visible to
|
||||||
|
</Label>
|
||||||
|
<div className="border-border rounded-md border">
|
||||||
{/* Everyone in network */}
|
{/* Everyone in network */}
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
onClick={() => setEveryone((prev) => !prev)}
|
onClick={() => setEveryone((prev) => !prev)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
||||||
'text-white/70 hover:bg-white/5',
|
'hover:bg-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -133,7 +138,7 @@ export function ConfigureStreamStep({
|
|||||||
onClick={() => toggleMember(member.id)}
|
onClick={() => toggleMember(member.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors',
|
'flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors',
|
||||||
'text-white/70 hover:bg-white/5',
|
'hover:bg-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -141,7 +146,7 @@ export function ConfigureStreamStep({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
/>
|
/>
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
|
<span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium">
|
||||||
{initials}
|
{initials}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
@@ -158,14 +163,14 @@ export function ConfigureStreamStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hints */}
|
{/* Keyboard hints */}
|
||||||
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
|
<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)">
|
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
||||||
cancel
|
cancel
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys={`${metaKey}+Enter`}
|
keys={`${metaKey}+Enter`}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
title={`Create stream (or press ${metaKey}+Enter)`}
|
title={`Create ${kind} (or press ${metaKey}+Enter)`}
|
||||||
>
|
>
|
||||||
create
|
create
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces.
|
/* Theme Milkdown Crepe to the app's design tokens so it follows light/dark.
|
||||||
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom
|
* Crepe's theme is a palette driven by --crepe-* custom properties; we map
|
||||||
* properties; we override those to white-on-transparent with a blue accent so
|
* those onto our CSS variables (globals.css) so the editor (and the read-only
|
||||||
* the editor (and the read-only display, which uses the same engine) sits on
|
* display, which uses the same engine) inherits the active theme and sits on
|
||||||
* top of the existing card instead of painting its own opaque background.
|
* top of the existing card instead of painting its own opaque background.
|
||||||
*
|
*
|
||||||
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled
|
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled
|
||||||
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those
|
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those
|
||||||
* selectors too, otherwise they fall back to transparent and the menu is
|
* selectors too, otherwise they fall back to transparent and the menu is
|
||||||
* unreadable over the content behind it. */
|
* unreadable over the content behind it. Our tokens resolve against the
|
||||||
|
* :root/.dark cascade on <html>, so the portaled menus stay themed too. */
|
||||||
.llink-crepe .milkdown,
|
.llink-crepe .milkdown,
|
||||||
.milkdown-slash-menu,
|
.milkdown-slash-menu,
|
||||||
.milkdown-toolbar,
|
.milkdown-toolbar,
|
||||||
@@ -15,24 +16,30 @@
|
|||||||
.milkdown-link-preview,
|
.milkdown-link-preview,
|
||||||
.milkdown-block-handle {
|
.milkdown-block-handle {
|
||||||
--crepe-color-background: transparent;
|
--crepe-color-background: transparent;
|
||||||
--crepe-color-on-background: rgb(255 255 255 / 0.92);
|
--crepe-color-on-background: var(--card-foreground);
|
||||||
/* Surface backs code blocks, tables, and the floating menus — keep it
|
/* Surface backs code blocks, tables, and the floating menus — use an opaque
|
||||||
* (near-)opaque so menus read clearly over content. */
|
* themed surface so menus read clearly over content. */
|
||||||
--crepe-color-surface: rgb(24 24 28 / 0.96);
|
--crepe-color-surface: var(--popover);
|
||||||
--crepe-color-surface-low: rgb(42 42 50 / 0.96);
|
--crepe-color-surface-low: var(--muted);
|
||||||
--crepe-color-on-surface: #ffffff;
|
--crepe-color-on-surface: var(--popover-foreground);
|
||||||
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.65);
|
--crepe-color-on-surface-variant: var(--muted-foreground);
|
||||||
--crepe-color-outline: rgb(255 255 255 / 0.2);
|
--crepe-color-outline: var(--border);
|
||||||
--crepe-color-primary: #60a5fa;
|
--crepe-color-primary: var(--primary);
|
||||||
--crepe-color-secondary: rgb(96 165 250 / 0.25);
|
--crepe-color-secondary: color-mix(in oklch, var(--primary) 22%, transparent);
|
||||||
--crepe-color-on-secondary: #ffffff;
|
--crepe-color-on-secondary: var(--foreground);
|
||||||
--crepe-color-inverse: #ffffff;
|
--crepe-color-inverse: var(--foreground);
|
||||||
--crepe-color-on-inverse: #0b0b0b;
|
--crepe-color-on-inverse: var(--background);
|
||||||
--crepe-color-inline-code: #fca5a5;
|
--crepe-color-inline-code: var(--primary);
|
||||||
--crepe-color-inline-area: rgb(255 255 255 / 0.1);
|
--crepe-color-inline-area: var(--muted);
|
||||||
--crepe-color-error: #f87171;
|
--crepe-color-error: var(--destructive);
|
||||||
--crepe-color-hover: rgb(255 255 255 / 0.08);
|
--crepe-color-hover: var(--accent);
|
||||||
--crepe-color-selected: rgb(96 165 250 / 0.25);
|
--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);
|
||||||
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
|
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
|
||||||
--crepe-font-default: inherit;
|
--crepe-font-default: inherit;
|
||||||
--crepe-font-title: inherit;
|
--crepe-font-title: inherit;
|
||||||
@@ -57,6 +64,66 @@
|
|||||||
.llink-crepe .milkdown .ProseMirror {
|
.llink-crepe .milkdown .ProseMirror {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
outline: none;
|
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
|
/* Editing context: fill the compose card and scroll internally so a long
|
||||||
@@ -71,8 +138,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Pad the content (not the card) so the slash menu — which Crepe appends to
|
/* Pad the content (not the card) so the slash menu — which Crepe appends to
|
||||||
* .milkdown — can use the full card width/height before clipping. */
|
* .milkdown — can use the full card width/height before clipping. The shared
|
||||||
|
* layout variables (globals.css) keep the editor's content column identical
|
||||||
|
* to the posted card's, and the gutter fits the block drag handle. */
|
||||||
.llink-crepe--fill .milkdown .ProseMirror {
|
.llink-crepe--fill .milkdown .ProseMirror {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 1.25rem;
|
padding: var(--message-card-padding) var(--message-editor-gutter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Crepe } from '@milkdown/crepe';
|
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/common/style.css';
|
||||||
import '@milkdown/crepe/theme/frame-dark.css';
|
import '@milkdown/crepe/theme/frame-dark.css';
|
||||||
import './markdown-editor.css';
|
import './markdown-editor.css';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { transferFiles } from '@/lib/data-transfer';
|
||||||
|
|
||||||
interface MarkdownEditorProps {
|
interface MarkdownEditorProps {
|
||||||
/** Initial markdown. The editor owns its content after mount; edits flow out
|
/** Initial markdown. The editor owns its content after mount; edits flow out
|
||||||
@@ -55,6 +58,11 @@ export function MarkdownEditor({
|
|||||||
[Crepe.Feature.BlockEdit]: !readOnly,
|
[Crepe.Feature.BlockEdit]: !readOnly,
|
||||||
[Crepe.Feature.Toolbar]: !readOnly,
|
[Crepe.Feature.Toolbar]: !readOnly,
|
||||||
[Crepe.Feature.Placeholder]: !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.ImageBlock]: false,
|
||||||
[Crepe.Feature.Latex]: false,
|
[Crepe.Feature.Latex]: false,
|
||||||
[Crepe.Feature.TopBar]: false,
|
[Crepe.Feature.TopBar]: false,
|
||||||
@@ -73,6 +81,21 @@ export function MarkdownEditor({
|
|||||||
onChangeRef.current?.(markdown);
|
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(() => {
|
crepe.create().then(() => {
|
||||||
@@ -82,7 +105,18 @@ export function MarkdownEditor({
|
|||||||
}
|
}
|
||||||
created = crepe;
|
created = crepe;
|
||||||
if (autoFocus && !readOnly) {
|
if (autoFocus && !readOnly) {
|
||||||
root.querySelector<HTMLElement>('.ProseMirror')?.focus();
|
// Place the caret at the end of the document — the editor often mounts
|
||||||
|
// mid-typing (immersive → card flip), where start-of-doc would strand
|
||||||
|
// the user. Selection-only transactions don't echo markdownUpdated.
|
||||||
|
crepe.editor.action((ctx) => {
|
||||||
|
const view = ctx.get(editorViewCtx);
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr
|
||||||
|
.setSelection(Selection.atEnd(view.state.doc))
|
||||||
|
.scrollIntoView(),
|
||||||
|
);
|
||||||
|
view.focus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +131,11 @@ export function MarkdownEditor({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={cn('llink-crepe', !readOnly && 'llink-crepe--fill', className)}
|
className={cn(
|
||||||
|
'llink-crepe',
|
||||||
|
!readOnly && 'llink-crepe--fill scrollbar-card',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Paperclip } from 'lucide-react';
|
import { Paperclip } from 'lucide-react';
|
||||||
import type { RecordingMode } from '@/stores/media-settings-store';
|
import type { RecordingMode } from '@/stores/media-settings-store';
|
||||||
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
||||||
@@ -7,6 +8,7 @@ import { useObjectUrl } from '@/hooks/use-object-url';
|
|||||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { RECORDING_MAX_DURATION_SECONDS } from '@/lib/constants';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { KeyHint } from '@/components/key-hint';
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||||
@@ -34,16 +36,41 @@ interface RecordingOverlayProps {
|
|||||||
objectFit?: 'cover' | 'contain';
|
objectFit?: 'cover' | 'contain';
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordingTimer() {
|
function useRecordingCountdown(active: boolean) {
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
if (!active) return;
|
||||||
setElapsed((prev) => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
setElapsed(0);
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return { elapsed, isWarning: elapsed >= RECORDING_MAX_DURATION_SECONDS };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordingTimer({
|
||||||
|
elapsed,
|
||||||
|
isWarning,
|
||||||
|
}: {
|
||||||
|
elapsed: number;
|
||||||
|
isWarning: boolean;
|
||||||
|
}) {
|
||||||
const minutes = Math.floor(elapsed / 60);
|
const minutes = Math.floor(elapsed / 60);
|
||||||
const seconds = elapsed % 60;
|
const seconds = elapsed % 60;
|
||||||
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
@@ -51,7 +78,14 @@ function RecordingTimer() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
||||||
<span className="font-mono text-sm text-white/80">{display}</span>
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-sm text-white/80',
|
||||||
|
isWarning && 'text-red-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,14 +171,31 @@ export function RecordingOverlay({
|
|||||||
const isLoading = isRecording && !mediaStream;
|
const isLoading = isRecording && !mediaStream;
|
||||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
|
|
||||||
|
const { elapsed, isWarning } = useRecordingCountdown(
|
||||||
|
isRecording && !isLoading,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
|
'bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center',
|
||||||
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
|
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
|
||||||
|
isRecording && isWarning && 'record-warning-glow',
|
||||||
)}
|
)}
|
||||||
{...(isReviewing ? dropZoneProps : {})}
|
{...(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 */}
|
{/* Loading state */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="z-10 flex flex-col items-center gap-2">
|
<div className="z-10 flex flex-col items-center gap-2">
|
||||||
@@ -179,13 +230,13 @@ export function RecordingOverlay({
|
|||||||
|
|
||||||
{/* Bottom gradient scrim for keyboard hint readability */}
|
{/* Bottom gradient scrim for keyboard hint readability */}
|
||||||
{(isRecording || isReviewing) && !isLoading && (
|
{(isRecording || isReviewing) && !isLoading && (
|
||||||
<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" />
|
<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" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top center: recording indicator */}
|
{/* Top center: recording indicator */}
|
||||||
<div className="absolute top-8 z-10">
|
<div className="absolute top-8 z-10">
|
||||||
{isRecording && !isLoading ? (
|
{isRecording && !isLoading ? (
|
||||||
<RecordingTimer />
|
<RecordingTimer elapsed={elapsed} isWarning={isWarning} />
|
||||||
) : isReviewing ? (
|
) : isReviewing ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-white/80">Review recording</span>
|
<span className="text-sm text-white/80">Review recording</span>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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,6 +1,8 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { Paperclip } from 'lucide-react';
|
import { Paperclip } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getImmersiveTextStyle } from '@/lib/immersive-text';
|
||||||
|
import { hasMarkdownFormatting } from '@/lib/markdown';
|
||||||
import { metaKey } from '@/lib/platform';
|
import { metaKey } from '@/lib/platform';
|
||||||
import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
|
import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
|
||||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||||
@@ -35,12 +37,6 @@ interface TextEditorProps {
|
|||||||
|
|
||||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
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({
|
export function TextEditor({
|
||||||
textContent,
|
textContent,
|
||||||
onTextChange,
|
onTextChange,
|
||||||
@@ -50,7 +46,9 @@ export function TextEditor({
|
|||||||
attachmentProps,
|
attachmentProps,
|
||||||
}: TextEditorProps) {
|
}: TextEditorProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [forceCardMode, setForceCardMode] = useState(false);
|
// Card mode latches: once the message needs the full editor, snapping back
|
||||||
|
// to immersive mid-edit would be jarring, so it stays for the session.
|
||||||
|
const [carded, setCarded] = useState(false);
|
||||||
|
|
||||||
const [debouncedText, setDebouncedText] = useState(textContent);
|
const [debouncedText, setDebouncedText] = useState(textContent);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,13 +59,16 @@ export function TextEditor({
|
|||||||
|
|
||||||
const attachmentCount = attachmentProps?.attachments.length ?? 0;
|
const attachmentCount = attachmentProps?.attachments.length ?? 0;
|
||||||
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
|
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
|
||||||
const immersive =
|
const shouldCard =
|
||||||
textContent.length < IMMERSIVE_CHAR_LIMIT &&
|
!carded &&
|
||||||
!hasEnrichments &&
|
(textContent.length >= IMMERSIVE_CHAR_LIMIT ||
|
||||||
!forceCardMode;
|
hasEnrichments ||
|
||||||
|
hasMarkdownFormatting(textContent));
|
||||||
|
if (shouldCard) setCarded(true);
|
||||||
|
const immersive = !carded;
|
||||||
|
|
||||||
// Keep the immersive textarea focused with the caret at the end when we
|
// Keep the immersive textarea focused with the caret at the end on mount.
|
||||||
// (re)enter it. The card-mode editor manages its own focus.
|
// The card-mode editor manages its own focus.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!immersive) return;
|
if (!immersive) return;
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
@@ -95,7 +96,7 @@ export function TextEditor({
|
|||||||
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setForceCardMode(true);
|
setCarded(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onCancel, onSubmit, textContent],
|
[onCancel, onSubmit, textContent],
|
||||||
@@ -111,7 +112,7 @@ export function TextEditor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const keyboardHints = (
|
const keyboardHints = (
|
||||||
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
<div className="text-muted-foreground absolute bottom-4 flex items-center gap-4 text-sm">
|
||||||
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
||||||
cancel
|
cancel
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -127,7 +128,7 @@ export function TextEditor({
|
|||||||
{immersive && (
|
{immersive && (
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys={`${metaKey}+M`}
|
keys={`${metaKey}+M`}
|
||||||
onClick={() => setForceCardMode(true)}
|
onClick={() => setCarded(true)}
|
||||||
title={`Switch to markdown editor (or press ${metaKey}+M)`}
|
title={`Switch to markdown editor (or press ${metaKey}+M)`}
|
||||||
>
|
>
|
||||||
markdown
|
markdown
|
||||||
@@ -160,8 +161,8 @@ export function TextEditor({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
||||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
||||||
)}
|
)}
|
||||||
{...dropZoneProps}
|
{...dropZoneProps}
|
||||||
>
|
>
|
||||||
@@ -173,7 +174,7 @@ export function TextEditor({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none',
|
'text-foreground placeholder-muted-foreground w-full resize-none border-none bg-transparent p-8 text-center outline-none',
|
||||||
style.size,
|
style.size,
|
||||||
style.weight,
|
style.weight,
|
||||||
)}
|
)}
|
||||||
@@ -188,13 +189,13 @@ export function TextEditor({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
||||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
||||||
)}
|
)}
|
||||||
{...dropZoneProps}
|
{...dropZoneProps}
|
||||||
onKeyDownCapture={handleKeyDown}
|
onKeyDownCapture={handleKeyDown}
|
||||||
>
|
>
|
||||||
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl">
|
<div className="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">
|
||||||
{/* No padding here: the editor's own scroll box hosts the slash menu,
|
{/* No padding here: the editor's own scroll box hosts the slash menu,
|
||||||
so we pad inside the editor (ProseMirror) instead. That keeps the
|
so we pad inside the editor (ProseMirror) instead. That keeps the
|
||||||
menu's clipping bounds the full card rather than the inset box. */}
|
menu's clipping bounds the full card rather than the inset box. */}
|
||||||
@@ -207,7 +208,7 @@ export function TextEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasEnrichments && strip && (
|
{hasEnrichments && strip && (
|
||||||
<div className="shrink-0 border-t border-white/10 px-5 py-3">
|
<div className="border-border shrink-0 border-t px-5 py-3">
|
||||||
{strip}
|
{strip}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,30 +16,12 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|||||||
import { useNetworks } from '@/hooks/use-networks';
|
import { useNetworks } from '@/hooks/use-networks';
|
||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { useParticle } from '@/hooks/use-particle';
|
import { useParticle } from '@/hooks/use-particle';
|
||||||
import type { Particle } from '@/api/types';
|
import { getParticleDisplayName } from '@/lib/particle-display';
|
||||||
import { PropsWithChildren, useCallback } from 'react';
|
import { PropsWithChildren, useCallback } from 'react';
|
||||||
import { useDockBadge } from '@/hooks/use-dock-badge';
|
import { useDockBadge } from '@/hooks/use-dock-badge';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { RouteErrorBoundary } from '@/components/app-error-boundary';
|
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 }) {
|
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
|
||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
const network = networks?.find((n) => n.id === networkId);
|
const network = networks?.find((n) => n.id === networkId);
|
||||||
|
|||||||
@@ -1,126 +1,15 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
||||||
import { CircleDot, CircleCheckBig } from 'lucide-react';
|
|
||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { ParticleListView } from '@/features/particles/particle-list-view';
|
import { ContainerView } from '@/features/particles/container-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).
|
* Route-level component for /:networkId (index). The network root behaves
|
||||||
* Shows root-level particles for the selected network.
|
* like a folder: a container of streams, folders, and loose particles.
|
||||||
*/
|
*/
|
||||||
export default function NetworkRoot() {
|
export default function NetworkRoot() {
|
||||||
const { networkId } = useParams();
|
const { networkId } = useParams();
|
||||||
if (!networkId)
|
if (!networkId)
|
||||||
throw new Error('NetworkRoot requires a :networkId route param');
|
throw new Error('NetworkRoot requires a :networkId route param');
|
||||||
const navigate = useNavigate();
|
|
||||||
const path = particlePath(networkId, []);
|
|
||||||
|
|
||||||
const [composeActive, setComposeActive] = useState(false);
|
return <ContainerView path={particlePath(networkId, [])} />;
|
||||||
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,6 +11,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@@ -45,11 +46,11 @@ function MemberRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||||
<Avatar>
|
<HumanAvatar
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
avatarObjectId={human.avatar_object_id}
|
||||||
{initials}
|
initials={initials}
|
||||||
</AvatarFallback>
|
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||||
</Avatar>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{human.email_prefix}</p>
|
<p className="truncate text-sm font-medium">{human.email_prefix}</p>
|
||||||
<Muted className="text-xs">{human.email}</Muted>
|
<Muted className="text-xs">{human.email}</Muted>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { CircleCheckBig, CircleDot, FolderIcon } from 'lucide-react';
|
||||||
|
import type { Particle } from '@/api/types';
|
||||||
|
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useContainerChildren } from '@/hooks/use-container-children';
|
||||||
|
import { useListKeyboardNav } from '@/hooks/use-list-keyboard-nav';
|
||||||
|
import {
|
||||||
|
useCreateParticle,
|
||||||
|
useCreateStreamParticle,
|
||||||
|
} from '@/hooks/use-create-particle';
|
||||||
|
import { ParticleChildrenList } from '@/features/particles/particle-children-list';
|
||||||
|
import { ComposeOverlay } from '@/features/compose/compose-overlay';
|
||||||
|
import { ComposeQuotaIndicator } from '@/features/compose/compose-quota-indicator';
|
||||||
|
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
|
||||||
|
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||||
|
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
|
type ContainerKind = 'stream' | 'folder';
|
||||||
|
|
||||||
|
interface ContainerViewProps {
|
||||||
|
/** Path of the container; network root when it has no segments. */
|
||||||
|
path: ParticlePath;
|
||||||
|
/** Present when the container is a folder particle (drives the header). */
|
||||||
|
folderParticle?: Particle & { type: 'folder' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browsable view of a container's children — used for both the network root
|
||||||
|
* and folders, which share the same structure: a mixed-type child list, the
|
||||||
|
* compose overlay, and one keyboard grammar. The root is just a folder
|
||||||
|
* without a doc.
|
||||||
|
*/
|
||||||
|
export function ContainerView({ path, folderParticle }: ContainerViewProps) {
|
||||||
|
const { networkId, segments } = parseParticlePath(path);
|
||||||
|
const isRoot = segments.length === 0;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
|
const [composeActive, setComposeActive] = useState(false);
|
||||||
|
const [creating, setCreating] = useState<ContainerKind | null>(null);
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const statusTab: 'open' | 'closed' =
|
||||||
|
searchParams.get('status') === 'closed' ? 'closed' : 'open';
|
||||||
|
const setStatusTab = (next: 'open' | 'closed') => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const params = new URLSearchParams(prev);
|
||||||
|
params.set('status', next);
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Folders are shelved for now: the root lists streams only, split by
|
||||||
|
// open/closed status (server-side filter, like before folders). Folder
|
||||||
|
// containers keep the mixed-type child list so the recursive container
|
||||||
|
// model can be revived later.
|
||||||
|
const { items, isLoading, canLoadMore, loadMore } = useContainerChildren(
|
||||||
|
path,
|
||||||
|
{ streamStatus: isRoot ? statusTab : undefined },
|
||||||
|
);
|
||||||
|
|
||||||
|
const createParticle = useCreateParticle();
|
||||||
|
const createStream = useCreateStreamParticle();
|
||||||
|
|
||||||
|
const handleOpen = useCallback(
|
||||||
|
(particleId: string) => {
|
||||||
|
navigate(`/${networkId}/${[...segments, particleId].join('/')}`);
|
||||||
|
},
|
||||||
|
[navigate, networkId, segments],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { selectedIndex } = useListKeyboardNav({
|
||||||
|
items,
|
||||||
|
enabled: !composeActive && creating === null,
|
||||||
|
onOpen: handleOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
// N creates a stream inside a folder. Root stream creation goes through the
|
||||||
|
// compose flow.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRoot || composeActive || creating !== null) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'n' || e.key === 'N') {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreating('stream');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isRoot, composeActive, creating]);
|
||||||
|
|
||||||
|
const handleCreateContainer = useCallback(
|
||||||
|
async (name: string, visibleTo: string[]) => {
|
||||||
|
if (!userId || !creating) return;
|
||||||
|
const kind = creating;
|
||||||
|
setCreating(null);
|
||||||
|
const id =
|
||||||
|
kind === 'folder'
|
||||||
|
? await createParticle.mutateAsync({
|
||||||
|
path,
|
||||||
|
type: 'folder',
|
||||||
|
properties: { name },
|
||||||
|
createdByHumanId: userId,
|
||||||
|
visibleTo,
|
||||||
|
})
|
||||||
|
: await createStream.mutateAsync({
|
||||||
|
networkId,
|
||||||
|
parentPath: path,
|
||||||
|
properties: { name },
|
||||||
|
createdByHumanId: userId,
|
||||||
|
visibleTo,
|
||||||
|
});
|
||||||
|
handleOpen(id);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
creating,
|
||||||
|
path,
|
||||||
|
networkId,
|
||||||
|
createParticle,
|
||||||
|
createStream,
|
||||||
|
handleOpen,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
|
{isRoot && (
|
||||||
|
<div className="flex shrink-0 items-center border-b p-1">
|
||||||
|
<Tabs
|
||||||
|
value={statusTab}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setStatusTab(v === 'closed' ? 'closed' : 'open')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="open">
|
||||||
|
<CircleDot className="size-3 text-green-500" /> Open
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="closed">
|
||||||
|
<CircleCheckBig className="size-3" /> Closed
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{folderParticle && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||||
|
<span className="flex size-7 items-center justify-center rounded-md bg-amber-500/15">
|
||||||
|
<FolderIcon className="size-4 text-amber-500" />
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-sm font-semibold">
|
||||||
|
{folderParticle.properties.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
|
||||||
|
<ParticleChildrenList
|
||||||
|
items={items}
|
||||||
|
networkId={networkId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onOpen={handleOpen}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
canLoadMore={canLoadMore}
|
||||||
|
onLoadMore={loadMore}
|
||||||
|
emptyMessage={
|
||||||
|
!isRoot
|
||||||
|
? 'This folder is empty. Add something using the keyboard shortcuts below.'
|
||||||
|
: statusTab === 'closed'
|
||||||
|
? 'No closed streams.'
|
||||||
|
: 'No streams here. Start a conversation using the keyboard shortcuts below.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ComposeOverlay
|
||||||
|
networkId={networkId}
|
||||||
|
targetPath={isRoot ? undefined : path}
|
||||||
|
onActiveChange={setComposeActive}
|
||||||
|
/>
|
||||||
|
{!composeActive && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
|
||||||
|
<ComposeQuotaIndicator networkId={networkId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<ContainerControls
|
||||||
|
isRoot={isRoot}
|
||||||
|
onCreateStream={() => setCreating('stream')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<ConfigureContainerStep
|
||||||
|
kind={creating}
|
||||||
|
networkId={networkId}
|
||||||
|
onCancel={() => setCreating(null)}
|
||||||
|
onSubmit={handleCreateContainer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerControls({
|
||||||
|
isRoot,
|
||||||
|
onCreateStream,
|
||||||
|
}: {
|
||||||
|
isRoot: boolean;
|
||||||
|
onCreateStream: () => void;
|
||||||
|
}) {
|
||||||
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
|
return (
|
||||||
|
<div className="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,21 +2,25 @@ import { useCallback, useState } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
|
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
|
||||||
import { softDeleteParticle } from '@/lib/firestore-particles';
|
import { softDeleteParticle } from '@/lib/firestore-particles';
|
||||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
import {
|
||||||
|
particlePath,
|
||||||
|
parseParticlePath,
|
||||||
|
toFirestoreDocPath,
|
||||||
|
type ParticlePath,
|
||||||
|
} from '@/lib/particle-path';
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
|
|
||||||
interface DeleteParticleOverlayProps {
|
interface DeleteParticleOverlayProps {
|
||||||
networkId: string;
|
/** Path of the stream the particle lives in — may be nested. */
|
||||||
streamId: string;
|
streamPath: ParticlePath;
|
||||||
particle: Particle;
|
particle: Particle;
|
||||||
userId: string;
|
userId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteParticleOverlay({
|
export function DeleteParticleOverlay({
|
||||||
networkId,
|
streamPath,
|
||||||
streamId,
|
|
||||||
particle,
|
particle,
|
||||||
userId,
|
userId,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -29,8 +33,9 @@ export function DeleteParticleOverlay({
|
|||||||
if (deleting) return;
|
if (deleting) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
|
const { networkId, segments } = parseParticlePath(streamPath);
|
||||||
const docPath = toFirestoreDocPath(
|
const docPath = toFirestoreDocPath(
|
||||||
particlePath(networkId, [streamId, particle.id]),
|
particlePath(networkId, [...segments, particle.id]),
|
||||||
);
|
);
|
||||||
await softDeleteParticle(docPath, userId);
|
await softDeleteParticle(docPath, userId);
|
||||||
toast.success('Particle deleted');
|
toast.success('Particle deleted');
|
||||||
@@ -41,7 +46,7 @@ export function DeleteParticleOverlay({
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}, [deleting, networkId, onClose, particle.id, streamId, userId]);
|
}, [deleting, onClose, particle.id, streamPath, userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmDestructiveOverlay
|
<ConfirmDestructiveOverlay
|
||||||
|
|||||||
@@ -38,14 +38,16 @@ export function DeletedParticleView({
|
|||||||
}, [paused, onEnded, particle.id]);
|
}, [paused, onEnded, particle.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8">
|
<div className="flex h-full w-full items-center justify-center px-8">
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
<Trash2 className="text-white/40 size-6" />
|
<Trash2 className="text-muted-foreground size-6" />
|
||||||
<p className="text-white/70 text-base font-medium">
|
<p className="text-foreground text-base font-medium">
|
||||||
This particle was deleted
|
This particle was deleted
|
||||||
</p>
|
</p>
|
||||||
{deleter && (
|
{deleter && (
|
||||||
<p className="text-white/40 text-xs">by {deleter.displayName}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
by {deleter.displayName}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,17 +6,11 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import {
|
import { FileIcon, HelpCircleIcon, BookOpenIcon } from 'lucide-react';
|
||||||
FileIcon,
|
|
||||||
HelpCircleIcon,
|
|
||||||
ScrollTextIcon,
|
|
||||||
BookOpenIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
|
|
||||||
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
|
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
|
||||||
quest: { icon: ScrollTextIcon, label: 'Quest' },
|
|
||||||
paper: { icon: BookOpenIcon, label: 'Paper' },
|
paper: { icon: BookOpenIcon, label: 'Paper' },
|
||||||
file: { icon: FileIcon, label: 'File' },
|
file: { icon: FileIcon, label: 'File' },
|
||||||
};
|
};
|
||||||
@@ -37,19 +31,19 @@ export function FallbackParticleView({
|
|||||||
);
|
);
|
||||||
const meta = TYPE_META[particle.type] ?? {
|
const meta = TYPE_META[particle.type] ?? {
|
||||||
icon: HelpCircleIcon,
|
icon: HelpCircleIcon,
|
||||||
label: particle.type,
|
label: particle.type === 'unknown' ? particle.raw_type : particle.type,
|
||||||
};
|
};
|
||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const title = (() => {
|
const title = (() => {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case 'quest':
|
|
||||||
return particle.properties.title;
|
|
||||||
case 'paper':
|
case 'paper':
|
||||||
return particle.properties.title;
|
return particle.properties.title;
|
||||||
case 'file':
|
case 'file':
|
||||||
return particle.properties.filename;
|
return particle.properties.filename;
|
||||||
case 'folder':
|
case 'folder':
|
||||||
return particle.properties.name;
|
return particle.properties.name;
|
||||||
|
case 'unknown':
|
||||||
|
return 'Not supported in this version of the app';
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { Particle } from '@/api/types';
|
import { Particle } from '@/api/types';
|
||||||
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
import { type ParticlePath } from '@/lib/particle-path';
|
||||||
import { ComposeOverlay } from '@/features/compose/compose-overlay';
|
import { ContainerView } from '@/features/particles/container-view';
|
||||||
|
|
||||||
interface FolderViewProps {
|
interface FolderViewProps {
|
||||||
folderParticle: Particle;
|
folderParticle: Particle & { type: 'folder' };
|
||||||
path: ParticlePath;
|
path: ParticlePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FolderView({ path, folderParticle }: FolderViewProps) {
|
export function FolderView({ path, folderParticle }: FolderViewProps) {
|
||||||
const { networkId } = parseParticlePath(path);
|
return <ContainerView path={path} folderParticle={folderParticle} />;
|
||||||
|
|
||||||
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) {
|
if (isAudio) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
<div className="bg-scrim/90 relative flex h-full w-full items-center justify-center">
|
||||||
<audio
|
<audio
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
audioRef.current = el;
|
audioRef.current = el;
|
||||||
@@ -186,7 +186,7 @@ export const MediaParticleView = forwardRef<
|
|||||||
playsInline
|
playsInline
|
||||||
onEnded={onEnded}
|
onEnded={onEnded}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
className="h-full w-full object-contain bg-black"
|
className="bg-black h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{transcript && (
|
{transcript && (
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function ImageAttachment({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || !url) {
|
if (isLoading || !url) {
|
||||||
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
|
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
@@ -78,7 +78,7 @@ function ImageAttachment({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onPreview();
|
onPreview();
|
||||||
}}
|
}}
|
||||||
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
|
className="bg-muted group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
@@ -89,7 +89,7 @@ function ImageAttachment({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Download className="size-3.5" />
|
<Download className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
@@ -123,14 +123,14 @@ function FileAttachment({
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
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"
|
className="bg-muted hover:bg-accent flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg px-3 py-2 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileIcon className="size-4 shrink-0 text-white/60" />
|
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
||||||
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
|
<span className="text-foreground max-w-[10rem] truncate text-xs font-medium">
|
||||||
{particle.properties.filename}
|
{particle.properties.filename}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-white/40">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{formatFileSize(particle.properties.size_bytes)}
|
{formatFileSize(particle.properties.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +138,7 @@ function FileAttachment({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
className="text-muted-foreground"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
>
|
>
|
||||||
<ExternalLink data-icon="inline-start" />
|
<ExternalLink data-icon="inline-start" />
|
||||||
@@ -147,7 +147,7 @@ function FileAttachment({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
className="text-muted-foreground"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
>
|
>
|
||||||
<Download data-icon="inline-start" />
|
<Download data-icon="inline-start" />
|
||||||
@@ -177,7 +177,7 @@ function CompactAttachmentItem({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openParticle(particle, index, url, onPreview);
|
openParticle(particle, index, url, onPreview);
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
url ? (
|
url ? (
|
||||||
@@ -187,15 +187,15 @@ function CompactAttachmentItem({
|
|||||||
className="size-5 shrink-0 rounded object-cover"
|
className="size-5 shrink-0 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="size-4 shrink-0 text-white/50" />
|
<ImageIcon className="text-muted-foreground size-4 shrink-0" />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<FileIcon className="size-4 shrink-0 text-white/50" />
|
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="min-w-0 truncate text-xs text-white/80">
|
<span className="text-foreground min-w-0 truncate text-xs">
|
||||||
{particle.properties.filename}
|
{particle.properties.filename}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-[10px] text-white/40">
|
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||||
{formatFileSize(particle.properties.size_bytes)}
|
{formatFileSize(particle.properties.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+210
-117
@@ -1,32 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
|
Fragment,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
|
||||||
memo,
|
memo,
|
||||||
createElement,
|
createElement,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { Headphones, Radio, FolderIcon } from 'lucide-react';
|
||||||
import {
|
|
||||||
Radio,
|
|
||||||
MessageSquare,
|
|
||||||
Video,
|
|
||||||
Mic,
|
|
||||||
Image,
|
|
||||||
FileText,
|
|
||||||
CircleCheck,
|
|
||||||
StickyNote,
|
|
||||||
Headphones,
|
|
||||||
Trash2,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn, getInitials } from '@/lib/utils';
|
import { cn, getInitials } from '@/lib/utils';
|
||||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Small } from '@/components/ui/typography';
|
import { Small } from '@/components/ui/typography';
|
||||||
@@ -36,12 +23,17 @@ import {
|
|||||||
type Particle,
|
type Particle,
|
||||||
type StreamProperties,
|
type StreamProperties,
|
||||||
} from '@/api/types';
|
} from '@/api/types';
|
||||||
import type { StreamParticle } from '@/hooks/use-stream-particles';
|
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
||||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||||
|
import { getMessagePreview, getParticleTypeIcon } from '@/lib/particle-display';
|
||||||
import { StreamContextMenu } from '@/features/particles/stream-context-menu';
|
import { StreamContextMenu } from '@/features/particles/stream-context-menu';
|
||||||
|
|
||||||
|
type StreamParticle = Particle & {
|
||||||
|
type: 'stream';
|
||||||
|
properties: StreamProperties;
|
||||||
|
};
|
||||||
|
|
||||||
function VideoThumbnail({
|
function VideoThumbnail({
|
||||||
objectId,
|
objectId,
|
||||||
isUnseen,
|
isUnseen,
|
||||||
@@ -71,65 +63,16 @@ function VideoThumbnail({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
|
||||||
if (isParticleDeleted(particle)) return Trash2;
|
|
||||||
switch (particle.type) {
|
|
||||||
case 'text':
|
|
||||||
return MessageSquare;
|
|
||||||
case 'media': {
|
|
||||||
const mime = particle.properties.mime_type;
|
|
||||||
if (mime.startsWith('video/')) return Video;
|
|
||||||
if (mime.startsWith('audio/')) return Mic;
|
|
||||||
if (mime.startsWith('image/')) return Image;
|
|
||||||
return Video;
|
|
||||||
}
|
|
||||||
case 'file':
|
|
||||||
return FileText;
|
|
||||||
case 'quest':
|
|
||||||
return CircleCheck;
|
|
||||||
case 'paper':
|
|
||||||
return StickyNote;
|
|
||||||
default:
|
|
||||||
return Radio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessagePreview(particle: Particle): string {
|
|
||||||
if (isParticleDeleted(particle)) return 'Deleted particle';
|
|
||||||
switch (particle.type) {
|
|
||||||
case 'text':
|
|
||||||
return particle.properties.content;
|
|
||||||
case 'media': {
|
|
||||||
const mime = particle.properties.mime_type;
|
|
||||||
if (mime.startsWith('image/')) return 'Photo';
|
|
||||||
if (mime.startsWith('video/') || mime.startsWith('audio/')) {
|
|
||||||
const transcriptText = particle.properties.transcript?.transcript;
|
|
||||||
if (transcriptText) return transcriptText;
|
|
||||||
return mime.startsWith('video/') ? 'Video clip' : 'Voice note';
|
|
||||||
}
|
|
||||||
return 'Media';
|
|
||||||
}
|
|
||||||
case 'file':
|
|
||||||
return particle.properties.filename;
|
|
||||||
case 'quest':
|
|
||||||
return particle.properties.title;
|
|
||||||
case 'paper':
|
|
||||||
return particle.properties.title;
|
|
||||||
default:
|
|
||||||
return particle.type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const StreamRow = memo(function StreamRow({
|
const StreamRow = memo(function StreamRow({
|
||||||
particle,
|
particle,
|
||||||
networkId,
|
networkId,
|
||||||
onNavigate,
|
onOpen,
|
||||||
isSelected,
|
isSelected,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
}: {
|
}: {
|
||||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
particle: StreamParticle;
|
||||||
networkId: string;
|
networkId: string;
|
||||||
onNavigate: (streamId: string) => void;
|
onOpen: (particleId: string) => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
shortcutKey?: number;
|
shortcutKey?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -150,7 +93,7 @@ const StreamRow = memo(function StreamRow({
|
|||||||
particle.visible_to.length === 2 &&
|
particle.visible_to.length === 2 &&
|
||||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||||
|
|
||||||
const initials = useMemo(() => {
|
const avatar = useMemo(() => {
|
||||||
if (isDM) {
|
if (isDM) {
|
||||||
const otherEntry = particle.visible_to.find(
|
const otherEntry = particle.visible_to.find(
|
||||||
(v) => v !== `human:${userId}`,
|
(v) => v !== `human:${userId}`,
|
||||||
@@ -158,7 +101,12 @@ const StreamRow = memo(function StreamRow({
|
|||||||
if (otherEntry) {
|
if (otherEntry) {
|
||||||
const otherId = otherEntry.replace('human:', '');
|
const otherId = otherEntry.replace('human:', '');
|
||||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||||
if (otherHuman) return getInitials(otherHuman.email);
|
if (otherHuman) {
|
||||||
|
return {
|
||||||
|
initials: getInitials(otherHuman.email),
|
||||||
|
avatarObjectId: otherHuman.avatar_object_id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +114,18 @@ const StreamRow = memo(function StreamRow({
|
|||||||
const creator = network?.humans?.find(
|
const creator = network?.humans?.find(
|
||||||
(h) => h.id === latestChild.created_by_human_id,
|
(h) => h.id === latestChild.created_by_human_id,
|
||||||
);
|
);
|
||||||
if (creator) return getInitials(creator.email);
|
if (creator) {
|
||||||
|
return {
|
||||||
|
initials: getInitials(creator.email),
|
||||||
|
avatarObjectId: creator.avatar_object_id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
return {
|
||||||
|
initials: particle.properties.name.slice(0, 2).toUpperCase(),
|
||||||
|
avatarObjectId: null,
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
isDM,
|
isDM,
|
||||||
particle.visible_to,
|
particle.visible_to,
|
||||||
@@ -224,9 +180,9 @@ const StreamRow = memo(function StreamRow({
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onNavigate(particle.id)}
|
onClick={() => onOpen(particle.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(particle.id);
|
if (e.key === 'Enter' || e.key === ' ') onOpen(particle.id);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
|
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
|
||||||
@@ -235,18 +191,19 @@ const StreamRow = memo(function StreamRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{shortcutKey && (
|
{shortcutKey && (
|
||||||
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
|
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
</kbd>
|
</kbd>
|
||||||
)}
|
)}
|
||||||
{videoThumbObjectId ? (
|
{videoThumbObjectId ? (
|
||||||
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
||||||
) : (
|
) : (
|
||||||
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
|
<HumanAvatar
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
className={cn(isUnseen && 'ring-2 ring-primary')}
|
||||||
{initials}
|
avatarObjectId={avatar.avatarObjectId}
|
||||||
</AvatarFallback>
|
initials={avatar.initials}
|
||||||
</Avatar>
|
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -263,8 +220,8 @@ const StreamRow = memo(function StreamRow({
|
|||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{hasActiveHuddle && (
|
{hasActiveHuddle && (
|
||||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
<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" />
|
<Headphones className="size-3 text-red-600 dark:text-red-400" />
|
||||||
<span className="text-[10px] font-medium text-red-400">
|
<span className="text-[10px] font-medium text-red-600 dark:text-red-400">
|
||||||
{huddleCount}
|
{huddleCount}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -308,34 +265,160 @@ const StreamRow = memo(function StreamRow({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ParticleListViewProps {
|
const FolderRow = memo(function FolderRow({
|
||||||
streams: StreamParticle[];
|
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[];
|
||||||
networkId: string;
|
networkId: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
/** Open (navigate into / select) a particle by id. */
|
||||||
|
onOpen: (particleId: string) => void;
|
||||||
selectedIndex?: number | null;
|
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. */
|
/** When true, render a footer that invokes onLoadMore. */
|
||||||
canLoadMore?: boolean;
|
canLoadMore?: boolean;
|
||||||
onLoadMore?: () => void;
|
onLoadMore?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of stream particles for a container (network root, folder, etc.).
|
* Browsable list of a container's children, any particle type. Used by the
|
||||||
|
* network root and folder views.
|
||||||
*/
|
*/
|
||||||
export function ParticleListView({
|
export function ParticleChildrenList({
|
||||||
streams,
|
items,
|
||||||
networkId,
|
networkId,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
onOpen,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
showShortcuts = true,
|
||||||
|
emptyMessage = 'Nothing here yet. Create something using the keyboard shortcuts below.',
|
||||||
canLoadMore,
|
canLoadMore,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
}: ParticleListViewProps) {
|
}: ParticleChildrenListProps) {
|
||||||
const navigate = useNavigate();
|
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
||||||
|
|
||||||
const navigateToStream = useCallback(
|
|
||||||
(streamId: string) => navigate(`/${networkId}/${streamId}`),
|
|
||||||
[navigate, networkId],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -351,42 +434,52 @@ export function ParticleListView({
|
|||||||
return <Progress />;
|
return <Progress />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streams.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
|
<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" />
|
<Radio className="text-muted-foreground size-8" />
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||||
No streams here. Start a conversation using the keyboard shortcuts
|
|
||||||
below.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{streams.map((stream, index) => (
|
{items.map((item, index) => {
|
||||||
<StreamContextMenu
|
const rowProps = {
|
||||||
key={stream.id}
|
networkId,
|
||||||
particle={stream}
|
onOpen,
|
||||||
networkId={networkId}
|
isSelected: index === selectedIndex,
|
||||||
>
|
shortcutKey: showShortcuts && index < 9 ? index + 1 : undefined,
|
||||||
|
};
|
||||||
|
const row = (
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
rowRefs.current[index] = el;
|
rowRefs.current[index] = el;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StreamRow
|
{item.type === 'stream' ? (
|
||||||
particle={stream}
|
<StreamRow particle={item} {...rowProps} />
|
||||||
networkId={networkId}
|
) : item.type === 'folder' ? (
|
||||||
onNavigate={navigateToStream}
|
<FolderRow particle={item} {...rowProps} />
|
||||||
isSelected={index === selectedIndex}
|
) : (
|
||||||
shortcutKey={index < 9 ? index + 1 : undefined}
|
<LeafRow particle={item} {...rowProps} />
|
||||||
/>
|
)}
|
||||||
{index < streams.length - 1 && <Separator className="px-4" />}
|
{index < items.length - 1 && <Separator className="px-4" />}
|
||||||
</div>
|
</div>
|
||||||
</StreamContextMenu>
|
);
|
||||||
))}
|
return item.type === 'stream' ? (
|
||||||
|
<StreamContextMenu
|
||||||
|
key={item.id}
|
||||||
|
particle={item}
|
||||||
|
networkId={networkId}
|
||||||
|
>
|
||||||
|
{row}
|
||||||
|
</StreamContextMenu>
|
||||||
|
) : (
|
||||||
|
<Fragment key={item.id}>{row}</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{canLoadMore && onLoadMore && (
|
{canLoadMore && onLoadMore && (
|
||||||
<div className="flex justify-center p-3">
|
<div className="flex justify-center p-3">
|
||||||
<Button variant="ghost" size="sm" onClick={onLoadMore}>
|
<Button variant="ghost" size="sm" onClick={onLoadMore}>
|
||||||
@@ -5,11 +5,12 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import {
|
import {
|
||||||
Video,
|
Video,
|
||||||
Mic,
|
Mic,
|
||||||
ScrollText,
|
CircleCheck,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export function ParticlePreview({ particle }: { particle: Particle }) {
|
export function ParticlePreview({ particle }: { particle: Particle }) {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
@@ -17,8 +18,8 @@ export function ParticlePreview({ particle }: { particle: Particle }) {
|
|||||||
return <TextPreview particle={particle} />;
|
return <TextPreview particle={particle} />;
|
||||||
case 'media':
|
case 'media':
|
||||||
return <MediaPreview particle={particle} />;
|
return <MediaPreview particle={particle} />;
|
||||||
case 'quest':
|
case 'task':
|
||||||
return <QuestPreview particle={particle} />;
|
return <TaskPreview particle={particle} />;
|
||||||
case 'paper':
|
case 'paper':
|
||||||
return <PaperPreview particle={particle} />;
|
return <PaperPreview particle={particle} />;
|
||||||
case 'file':
|
case 'file':
|
||||||
@@ -68,9 +69,9 @@ function MediaPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
|
<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 text-white/60" />
|
<Mic className="h-8 w-8" />
|
||||||
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
|
<span className="font-mono text-xs">{durationLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,8 +103,8 @@ function VideoThumbnail({
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-black/80">
|
<div className="bg-muted flex h-full w-full items-center justify-center">
|
||||||
<Video className="h-8 w-8 text-white/40" />
|
<Video className="text-muted-foreground h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,7 +114,7 @@ function VideoThumbnail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full bg-black">
|
<div className="bg-muted relative h-full w-full">
|
||||||
<video
|
<video
|
||||||
src={`${url}#t=2`}
|
src={`${url}#t=2`}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
@@ -121,28 +122,37 @@ function VideoThumbnail({
|
|||||||
playsInline
|
playsInline
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<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">
|
<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">
|
||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuestPreview({
|
function TaskPreview({
|
||||||
particle,
|
particle,
|
||||||
}: {
|
}: {
|
||||||
particle: Extract<Particle, { type: 'quest' }>;
|
particle: Extract<Particle, { type: 'task' }>;
|
||||||
}) {
|
}) {
|
||||||
const { title, status } = particle.properties;
|
const { title, done } = particle.properties;
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
|
||||||
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
|
<CircleCheck
|
||||||
<p className="line-clamp-2 text-center text-sm font-medium">{title}</p>
|
className={cn(
|
||||||
{status && (
|
'h-6 w-6',
|
||||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
done
|
||||||
{status}
|
? 'text-emerald-600/80 dark:text-emerald-400/80'
|
||||||
</span>
|
: 'text-amber-600/70 dark:text-amber-400/70',
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'line-clamp-2 text-center text-sm font-medium',
|
||||||
|
done && 'text-muted-foreground line-through',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import { useEffect } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Lock } from 'lucide-react';
|
import { Lock } from 'lucide-react';
|
||||||
|
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||||
import { useLiveParticle } from '@/hooks/use-particle';
|
import { useLiveParticle } from '@/hooks/use-particle';
|
||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath, type ParticlePath } from '@/lib/particle-path';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
import Layout from '@/features/layout';
|
||||||
import { StreamView } from '@/features/particles/stream-view';
|
import { StreamView } from '@/features/particles/stream-view';
|
||||||
import { FolderView } from '@/features/particles/folder-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/*.
|
* Route-level component for /:networkId/*.
|
||||||
@@ -25,9 +32,11 @@ export default function ParticleViewResolver() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<Layout>
|
||||||
<p className="text-muted-foreground text-sm">Loading...</p>
|
<div className="flex h-full items-center justify-center">
|
||||||
</div>
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,25 +44,106 @@ export default function ParticleViewResolver() {
|
|||||||
// Errors here are almost always Firestore permission-denied — the user lost
|
// Errors here are almost always Firestore permission-denied — the user lost
|
||||||
// access to the network or to a custom-visibility particle. The React Router
|
// 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.
|
// stays on the dead route, so without an explicit escape the user is stuck.
|
||||||
return <InaccessibleParticle />;
|
return (
|
||||||
|
<Layout>
|
||||||
|
<InaccessibleParticle />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Streams render their own full-screen chrome; folders and leaves live
|
||||||
|
// inside the app Layout (breadcrumbs, full-height column) like the root.
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case 'stream':
|
case 'stream':
|
||||||
return <StreamView streamParticle={particle} path={path} />;
|
return <StreamView streamParticle={particle} path={path} />;
|
||||||
case 'folder':
|
case 'folder':
|
||||||
return <FolderView folderParticle={particle} path={path} />;
|
return (
|
||||||
|
<Layout>
|
||||||
|
<FolderView folderParticle={particle} path={path} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<Layout>
|
||||||
<p className="text-muted-foreground text-sm">
|
<LeafParticleView
|
||||||
{particle.type} particle: {particle.id}
|
particle={particle}
|
||||||
</p>
|
containerPath={particlePath(networkId, segments.slice(0, -1))}
|
||||||
</div>
|
networkId={networkId}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone view for a leaf particle opened directly (e.g. from a folder),
|
||||||
|
* outside any stream playback: renders the particle's native view with no
|
||||||
|
* auto-advance.
|
||||||
|
*/
|
||||||
|
function LeafParticleView({
|
||||||
|
particle,
|
||||||
|
containerPath,
|
||||||
|
networkId,
|
||||||
|
}: {
|
||||||
|
particle: Particle;
|
||||||
|
containerPath: ParticlePath;
|
||||||
|
networkId: string;
|
||||||
|
}) {
|
||||||
|
const content = (() => {
|
||||||
|
if (isParticleDeleted(particle)) {
|
||||||
|
return (
|
||||||
|
<DeletedParticleView
|
||||||
|
particle={particle}
|
||||||
|
networkId={networkId}
|
||||||
|
paused
|
||||||
|
onEnded={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (particle.type) {
|
||||||
|
case 'media':
|
||||||
|
return (
|
||||||
|
<MediaParticleView
|
||||||
|
particle={particle}
|
||||||
|
streamPath={containerPath}
|
||||||
|
paused={false}
|
||||||
|
onEnded={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<TextParticleView
|
||||||
|
particle={particle}
|
||||||
|
streamPath={containerPath}
|
||||||
|
paused
|
||||||
|
onEnded={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'task':
|
||||||
|
return (
|
||||||
|
<TaskParticleView
|
||||||
|
particle={particle}
|
||||||
|
containerPath={containerPath}
|
||||||
|
paused
|
||||||
|
onEnded={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<FallbackParticleView particle={particle} networkId={networkId} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background min-h-0 flex-1 [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InaccessibleParticle() {
|
function InaccessibleParticle() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -77,10 +77,10 @@ export function PlaybackPageIndicator({
|
|||||||
className="group relative block h-3 w-full"
|
className="group relative block h-3 w-full"
|
||||||
>
|
>
|
||||||
{/* Dim track */}
|
{/* Dim track */}
|
||||||
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
|
<div className="bg-muted-foreground/30 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
|
||||||
{/* Fill */}
|
{/* Fill */}
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
|
className="bg-foreground absolute bottom-0 left-0 h-[3px] transition-all group-hover:h-1.5"
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
i < current
|
i < current
|
||||||
@@ -107,7 +107,7 @@ export function PlaybackPageIndicator({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{paginated && showTracks && current >= 0 && (
|
{paginated && showTracks && current >= 0 && (
|
||||||
<div className="pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide text-white/40">
|
<div className="text-muted-foreground pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide">
|
||||||
{current + 1} / {total}
|
{current + 1} / {total}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -141,7 +141,7 @@ function GhostStub({
|
|||||||
className="group relative block h-3 w-2 shrink-0"
|
className="group relative block h-3 w-2 shrink-0"
|
||||||
aria-label="Jump to adjacent page"
|
aria-label="Jump to adjacent page"
|
||||||
>
|
>
|
||||||
<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" />
|
<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" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,18 +161,16 @@ function SegmentPresenceAvatars({
|
|||||||
{visible.map((human) => (
|
{visible.map((human) => (
|
||||||
<Tooltip key={human.humanId}>
|
<Tooltip key={human.humanId}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Avatar
|
<HumanAvatar
|
||||||
size="xs"
|
size="xs"
|
||||||
className={
|
className={
|
||||||
onlineHumanIds?.has(human.humanId)
|
onlineHumanIds?.has(human.humanId)
|
||||||
? 'ring-2 ring-green-500'
|
? 'ring-2 ring-green-500'
|
||||||
: 'ring-1 ring-black/50'
|
: 'ring-1 ring-background'
|
||||||
}
|
}
|
||||||
>
|
avatarObjectId={human.avatarObjectId}
|
||||||
<AvatarFallback>
|
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||||
{human.emailPrefix.slice(0, 2).toUpperCase()}
|
/>
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="text-xs">
|
<TooltipContent side="top" className="text-xs">
|
||||||
{human.email}
|
{human.email}
|
||||||
@@ -180,7 +178,9 @@ function SegmentPresenceAvatars({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
|
<span className="text-muted-foreground pl-1 text-[10px]">
|
||||||
|
+{overflow}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
@@ -77,12 +77,12 @@ export function ReactionBar({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
|
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
|
||||||
isMine
|
isMine
|
||||||
? 'bg-white/20 ring-1 ring-white/40'
|
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
||||||
: 'bg-black/40 hover:bg-black/50',
|
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-sm">{emoji}</span>
|
<span className="text-sm">{emoji}</span>
|
||||||
<span className="text-white/80">{reactors.length}</span>
|
<span className="text-muted-foreground">{reactors.length}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left" className="text-xs">
|
<TooltipContent side="left" className="text-xs">
|
||||||
@@ -109,18 +109,20 @@ export function ReactionBar({
|
|||||||
className={cn(
|
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',
|
'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
|
isMine
|
||||||
? 'bg-white/20 ring-1 ring-white/40'
|
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
||||||
: 'bg-black/40 hover:bg-black/50',
|
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Avatar size="xs" className="shrink-0">
|
<HumanAvatar
|
||||||
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white">
|
size="xs"
|
||||||
{firstReactor.initials}
|
className="shrink-0"
|
||||||
</AvatarFallback>
|
avatarObjectId={firstReactor.avatarObjectId}
|
||||||
</Avatar>
|
initials={firstReactor.initials}
|
||||||
<span className="truncate text-white/90">{text}</span>
|
fallbackClassName="bg-muted text-muted-foreground text-[9px] font-medium"
|
||||||
|
/>
|
||||||
|
<span className="truncate text-foreground">{text}</span>
|
||||||
{reactors.length > 1 && (
|
{reactors.length > 1 && (
|
||||||
<span className="shrink-0 text-white/60">
|
<span className="text-muted-foreground shrink-0">
|
||||||
{reactors.length}
|
{reactors.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -152,7 +154,7 @@ export function ReactionBar({
|
|||||||
|
|
||||||
{/* Picker / actions */}
|
{/* Picker / actions */}
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="flex flex-col items-center gap-0.5 rounded-full bg-black/40 px-0.5 py-1.5 backdrop-blur-sm">
|
<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">
|
||||||
{REACTION_EMOJIS.map((emoji) => {
|
{REACTION_EMOJIS.map((emoji) => {
|
||||||
if (activeEmojis.includes(emoji)) return null;
|
if (activeEmojis.includes(emoji)) return null;
|
||||||
return (
|
return (
|
||||||
@@ -162,7 +164,7 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleToggle(emoji);
|
handleToggle(emoji);
|
||||||
}}
|
}}
|
||||||
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
|
className="hover:bg-accent rounded-full px-0.5 py-1 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{emoji}
|
{emoji}
|
||||||
</button>
|
</button>
|
||||||
@@ -173,9 +175,9 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}}
|
}}
|
||||||
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
|
className="hover:bg-accent flex size-5 items-center justify-center rounded-full transition-colors"
|
||||||
>
|
>
|
||||||
<X className="size-3 text-white/60" />
|
<X className="text-muted-foreground size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -187,14 +189,14 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenTextReaction();
|
onOpenTextReaction();
|
||||||
}}
|
}}
|
||||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||||
>
|
>
|
||||||
<Type className="size-3 text-white/60" />
|
<Type className="text-muted-foreground size-3" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left" className="text-xs">
|
<TooltipContent side="left" className="text-xs">
|
||||||
Quick reply{' '}
|
Quick reply{' '}
|
||||||
<kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">
|
<kbd className="bg-muted ml-1 rounded px-1 font-mono text-[10px]">
|
||||||
R
|
R
|
||||||
</kbd>
|
</kbd>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -204,9 +206,9 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
}}
|
}}
|
||||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="size-3 text-white/60" />
|
<Plus className="text-muted-foreground size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { KeyHint } from '@/components/key-hint';
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { updateParticleProperties } from '@/lib/firestore-particles';
|
import { updateParticleProperties } from '@/lib/firestore-particles';
|
||||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
|
|
||||||
interface RenameStreamOverlayProps {
|
interface RenameStreamOverlayProps {
|
||||||
networkId: string;
|
streamPath: ParticlePath;
|
||||||
streamParticle: Particle & { type: 'stream' };
|
streamParticle: Particle & { type: 'stream' };
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RenameStreamOverlay({
|
export function RenameStreamOverlay({
|
||||||
networkId,
|
streamPath,
|
||||||
streamParticle,
|
streamParticle,
|
||||||
onClose,
|
onClose,
|
||||||
}: RenameStreamOverlayProps) {
|
}: RenameStreamOverlayProps) {
|
||||||
@@ -32,15 +32,13 @@ export function RenameStreamOverlay({
|
|||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const docPath = toFirestoreDocPath(
|
const docPath = toFirestoreDocPath(streamPath);
|
||||||
particlePath(networkId, [streamParticle.id]),
|
|
||||||
);
|
|
||||||
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
|
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
|
||||||
onClose();
|
onClose();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [canSave, networkId, onClose, streamParticle.id, trimmed]);
|
}, [canSave, streamPath, onClose, trimmed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -58,17 +56,17 @@ export function RenameStreamOverlay({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100]">
|
<div className="fixed inset-0 z-[100]">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<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="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="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
|
<h2 className="text-sm font-semibold">Rename stream</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc)"
|
title="Close (or press Esc)"
|
||||||
className="text-xs text-white/30"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -87,7 +85,6 @@ export function RenameStreamOverlay({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Stream name"
|
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">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { forwardRef, useMemo } from 'react';
|
|
||||||
import { Headphones } from 'lucide-react';
|
|
||||||
import { cn, getInitials } from '@/lib/utils';
|
|
||||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
|
||||||
import { particlePath } from '@/lib/particle-path';
|
|
||||||
import type { Particle, StreamProperties } from '@/api/types';
|
|
||||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
|
||||||
import { ParticlePreview } from '@/features/particles/particle-preview';
|
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
||||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
|
||||||
import { Small } from '@/components/ui/typography';
|
|
||||||
|
|
||||||
interface StreamCardProps {
|
|
||||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
|
||||||
networkId: string;
|
|
||||||
onClick: () => void;
|
|
||||||
isSelected?: boolean;
|
|
||||||
shortcutKey?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
|
|
||||||
function StreamCard(
|
|
||||||
{ particle, networkId, onClick, isSelected, shortcutKey },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const streamPath = particlePath(networkId, [particle.id]);
|
|
||||||
const { latestChild } = useLiveLatestChild(streamPath);
|
|
||||||
const userId = useAuthStore((s) => s.user?.id) ?? '';
|
|
||||||
const network = useNetwork(networkId);
|
|
||||||
|
|
||||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
|
||||||
|
|
||||||
const hasActiveHuddle =
|
|
||||||
particle.huddle_active_participants &&
|
|
||||||
particle.huddle_active_participants.length > 0;
|
|
||||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
|
||||||
|
|
||||||
const isDM =
|
|
||||||
particle.visible_to.length === 2 &&
|
|
||||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
|
||||||
|
|
||||||
const initials = useMemo(() => {
|
|
||||||
if (isDM) {
|
|
||||||
const otherEntry = particle.visible_to.find(
|
|
||||||
(v) => v !== `human:${userId}`,
|
|
||||||
);
|
|
||||||
if (otherEntry) {
|
|
||||||
const otherId = otherEntry.replace('human:', '');
|
|
||||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
|
||||||
if (otherHuman) return getInitials(otherHuman.email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latestChild) {
|
|
||||||
const creator = network?.humans?.find(
|
|
||||||
(h) => h.id === latestChild.created_by_human_id,
|
|
||||||
);
|
|
||||||
if (creator) return getInitials(creator.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
|
||||||
}, [
|
|
||||||
isDM,
|
|
||||||
particle.visible_to,
|
|
||||||
particle.properties.name,
|
|
||||||
userId,
|
|
||||||
latestChild,
|
|
||||||
network,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isUnseen = useMemo(() => {
|
|
||||||
if (!latestChild) return false;
|
|
||||||
const latestChildTimestamp = latestChild.created_at.getTime();
|
|
||||||
const userPlaybackPosition =
|
|
||||||
particle.playback_markers?.[userId]?.getTime() ?? 0;
|
|
||||||
return latestChildTimestamp > userPlaybackPosition;
|
|
||||||
}, [latestChild, particle.playback_markers, userId]);
|
|
||||||
|
|
||||||
// For media particles with a transcript, show it as an overlay on the preview
|
|
||||||
const transcript =
|
|
||||||
latestChild?.type === 'media'
|
|
||||||
? latestChild.properties.transcript?.transcript
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') onClick();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20',
|
|
||||||
isUnseen && 'ring-2 ring-primary',
|
|
||||||
isSelected && 'ring-2 ring-ring',
|
|
||||||
hasActiveHuddle && 'ring-2 ring-red-500/70',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Preview area */}
|
|
||||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
|
||||||
{hasActiveHuddle && (
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
|
||||||
)}
|
|
||||||
{shortcutKey && (
|
|
||||||
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
|
||||||
{shortcutKey}
|
|
||||||
</kbd>
|
|
||||||
)}
|
|
||||||
{latestChild ? (
|
|
||||||
<ParticlePreview particle={latestChild} />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<p className="text-muted-foreground text-xs italic">
|
|
||||||
No messages yet
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Transcript overlay for media with transcripts */}
|
|
||||||
{transcript && (
|
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
|
|
||||||
<p className="line-clamp-2 text-md leading-snug text-white/90">
|
|
||||||
{transcript}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info bar */}
|
|
||||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
|
||||||
<Avatar
|
|
||||||
className={cn('size-6 shrink-0', isUnseen && 'ring-2 ring-primary')}
|
|
||||||
>
|
|
||||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
|
||||||
{initials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<Small
|
|
||||||
className={cn(
|
|
||||||
'min-w-0 truncate',
|
|
||||||
isUnseen
|
|
||||||
? 'font-semibold text-foreground'
|
|
||||||
: 'font-medium text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{particle.properties.name}
|
|
||||||
</Small>
|
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
||||||
{hasActiveHuddle && (
|
|
||||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
|
||||||
<Headphones className="size-3 text-red-400" />
|
|
||||||
<span className="text-[10px] font-medium text-red-400">
|
|
||||||
{huddleCount}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{latestChild && (
|
|
||||||
<Small
|
|
||||||
className={cn(
|
|
||||||
'text-[10px]',
|
|
||||||
isUnseen ? 'text-primary' : 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RelativeTimestamp date={latestChild.created_at} />
|
|
||||||
</Small>
|
|
||||||
)}
|
|
||||||
{isUnseen && (
|
|
||||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
import { CircleCheckBig, CircleDot } from 'lucide-react';
|
import { CircleCheckBig, CircleDot } from 'lucide-react';
|
||||||
import { updateStreamStatus } from '@/lib/firestore-particles';
|
import { updateStreamStatus } from '@/lib/firestore-particles';
|
||||||
import { toFirestoreDocPath, particlePath } from '@/lib/particle-path';
|
import { toFirestoreDocPath, particlePath } from '@/lib/particle-path';
|
||||||
import type { StreamParticle } from '@/hooks/use-stream-particles';
|
import { isStreamOpen, type Particle } from '@/api/types';
|
||||||
|
|
||||||
interface StreamContextMenuProps {
|
interface StreamContextMenuProps {
|
||||||
particle: StreamParticle;
|
particle: Extract<Particle, { type: 'stream' }>;
|
||||||
networkId: string;
|
networkId: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ export function StreamContextMenu({
|
|||||||
networkId,
|
networkId,
|
||||||
children,
|
children,
|
||||||
}: StreamContextMenuProps) {
|
}: StreamContextMenuProps) {
|
||||||
const isOpen = particle.status === 'open';
|
const isOpen = isStreamOpen(particle);
|
||||||
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
|
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
|
||||||
|
|
||||||
const toggleStatus = async () => {
|
const toggleStatus = async () => {
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
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 { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
|
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { KeyHint } from '@/components/key-hint';
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
parseVisibleTo,
|
parseVisibleTo,
|
||||||
} from '@/lib/stream-visibility';
|
} from '@/lib/stream-visibility';
|
||||||
import { updateParticleVisibleTo } from '@/lib/firestore-particles';
|
import { updateParticleVisibleTo } from '@/lib/firestore-particles';
|
||||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { cn, getInitials } from '@/lib/utils';
|
import { cn, getInitials } from '@/lib/utils';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
@@ -19,6 +19,7 @@ import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
|||||||
|
|
||||||
interface StreamMembersOverlayProps {
|
interface StreamMembersOverlayProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
|
streamPath: ParticlePath;
|
||||||
streamParticle: Particle & { type: 'stream' };
|
streamParticle: Particle & { type: 'stream' };
|
||||||
isCreator: boolean;
|
isCreator: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -26,6 +27,7 @@ interface StreamMembersOverlayProps {
|
|||||||
|
|
||||||
export function StreamMembersOverlay({
|
export function StreamMembersOverlay({
|
||||||
networkId,
|
networkId,
|
||||||
|
streamPath,
|
||||||
streamParticle,
|
streamParticle,
|
||||||
isCreator,
|
isCreator,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -40,10 +42,7 @@ export function StreamMembersOverlay({
|
|||||||
[streamParticle.visible_to, networkId],
|
[streamParticle.visible_to, networkId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const docPath = useMemo(
|
const docPath = useMemo(() => toFirestoreDocPath(streamPath), [streamPath]);
|
||||||
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
|
||||||
[networkId, streamParticle.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const memberIds =
|
const memberIds =
|
||||||
visibility.mode === 'network'
|
visibility.mode === 'network'
|
||||||
@@ -98,18 +97,18 @@ export function StreamMembersOverlay({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100]">
|
<div className="fixed inset-0 z-[100]">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="bg-scrim/60 absolute inset-0 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<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">
|
<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">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">Members</h2>
|
<h2 className="text-sm font-semibold">Members</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc)"
|
title="Close (or press Esc)"
|
||||||
className="text-xs text-white/30"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -117,11 +116,11 @@ export function StreamMembersOverlay({
|
|||||||
|
|
||||||
{/* Visibility */}
|
{/* Visibility */}
|
||||||
<section className="mb-4">
|
<section className="mb-4">
|
||||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
||||||
Visibility
|
Visibility
|
||||||
</h3>
|
</h3>
|
||||||
{isCreator ? (
|
{isCreator ? (
|
||||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
|
<div className="bg-muted grid grid-cols-2 gap-1 rounded-lg p-1">
|
||||||
<VisibilityPill
|
<VisibilityPill
|
||||||
active={visibility.mode === 'network'}
|
active={visibility.mode === 'network'}
|
||||||
icon={<Globe className="size-3.5" />}
|
icon={<Globe className="size-3.5" />}
|
||||||
@@ -136,15 +135,15 @@ export function StreamMembersOverlay({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-sm text-white/70">
|
<div className="text-foreground flex items-center gap-2 text-sm">
|
||||||
{visibility.mode === 'network' ? (
|
{visibility.mode === 'network' ? (
|
||||||
<>
|
<>
|
||||||
<Globe className="size-3.5 text-white/40" />
|
<Globe className="text-muted-foreground size-3.5" />
|
||||||
<span>Everyone in {network?.name ?? 'network'}</span>
|
<span>Everyone in {network?.name ?? 'network'}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Lock className="size-3.5 text-white/40" />
|
<Lock className="text-muted-foreground size-3.5" />
|
||||||
<span>{memberIds.length} specific people</span>
|
<span>{memberIds.length} specific people</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -154,9 +153,11 @@ export function StreamMembersOverlay({
|
|||||||
|
|
||||||
{/* Member list */}
|
{/* Member list */}
|
||||||
<section className="flex min-h-0 flex-1 flex-col">
|
<section className="flex min-h-0 flex-1 flex-col">
|
||||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
||||||
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
||||||
<span className="ml-1 text-white/20">{memberIds.length}</span>
|
<span className="text-muted-foreground ml-1">
|
||||||
|
{memberIds.length}
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<ul className="flex flex-col gap-0.5 pr-2">
|
<ul className="flex flex-col gap-0.5 pr-2">
|
||||||
@@ -168,23 +169,24 @@ export function StreamMembersOverlay({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={id}
|
key={id}
|
||||||
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
|
className="text-foreground group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<Avatar size="sm">
|
<HumanAvatar
|
||||||
<AvatarFallback className="text-[10px]">
|
size="sm"
|
||||||
{display.initials}
|
avatarObjectId={display.avatarObjectId}
|
||||||
</AvatarFallback>
|
initials={display.initials}
|
||||||
</Avatar>
|
fallbackClassName="text-[10px]"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 truncate',
|
'flex-1 truncate',
|
||||||
!display.exists && 'italic text-white/40',
|
!display.exists && 'text-muted-foreground italic',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{display.displayName}
|
{display.displayName}
|
||||||
</span>
|
</span>
|
||||||
{isCreatorRow && (
|
{isCreatorRow && (
|
||||||
<span className="text-[10px] uppercase tracking-wider text-white/30">
|
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
|
||||||
Creator
|
Creator
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -192,7 +194,7 @@ export function StreamMembersOverlay({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeMember(id)}
|
onClick={() => removeMember(id)}
|
||||||
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100"
|
className="text-muted-foreground hover:bg-accent hover:text-foreground rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
aria-label={`Remove ${display.displayName}`}
|
aria-label={`Remove ${display.displayName}`}
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
@@ -209,8 +211,8 @@ export function StreamMembersOverlay({
|
|||||||
{isCreator &&
|
{isCreator &&
|
||||||
visibility.mode === 'custom' &&
|
visibility.mode === 'custom' &&
|
||||||
availableToAdd.length > 0 && (
|
availableToAdd.length > 0 && (
|
||||||
<section className="mt-4 border-t border-white/5 pt-4">
|
<section className="border-border mt-4 border-t pt-4">
|
||||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
<h3 className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest">
|
||||||
<UserPlus className="size-3" />
|
<UserPlus className="size-3" />
|
||||||
Add people
|
Add people
|
||||||
</h3>
|
</h3>
|
||||||
@@ -222,18 +224,19 @@ export function StreamMembersOverlay({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => addMember(human.id)}
|
onClick={() => addMember(human.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'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',
|
'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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Avatar size="sm">
|
<HumanAvatar
|
||||||
<AvatarFallback className="text-[10px]">
|
size="sm"
|
||||||
{getInitials(human.email)}
|
avatarObjectId={human.avatar_object_id}
|
||||||
</AvatarFallback>
|
initials={getInitials(human.email)}
|
||||||
</Avatar>
|
fallbackClassName="text-[10px]"
|
||||||
|
/>
|
||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
{human.email_prefix}
|
{human.email_prefix}
|
||||||
</span>
|
</span>
|
||||||
<UserPlus className="size-3.5 text-white/30" />
|
<UserPlus className="text-muted-foreground size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -245,7 +248,7 @@ export function StreamMembersOverlay({
|
|||||||
{isCreator &&
|
{isCreator &&
|
||||||
visibility.mode === 'custom' &&
|
visibility.mode === 'custom' &&
|
||||||
availableToAdd.length === 0 && (
|
availableToAdd.length === 0 && (
|
||||||
<p className="mt-4 text-center text-xs text-white/30">
|
<p className="text-muted-foreground mt-4 text-center text-xs">
|
||||||
<Users className="mr-1 inline size-3" />
|
<Users className="mr-1 inline size-3" />
|
||||||
Everyone in the network is already a member
|
Everyone in the network is already a member
|
||||||
</p>
|
</p>
|
||||||
@@ -274,8 +277,8 @@ function VisibilityPill({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
|
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
|
||||||
active
|
active
|
||||||
? 'bg-white/10 text-white/90'
|
? 'bg-background text-foreground shadow-sm'
|
||||||
: 'text-white/50 hover:text-white/80',
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { isParticleDeleted, type Particle } from '@/api/types';
|
import { isParticleDeleted, isStreamOpen, type Particle } from '@/api/types';
|
||||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
import { AvatarGroup } from '@/components/ui/avatar';
|
||||||
import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -19,20 +18,21 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Settings,
|
|
||||||
CircleCheckBig,
|
|
||||||
CircleDot,
|
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
Pencil,
|
Pencil,
|
||||||
Lock,
|
Lock,
|
||||||
Globe,
|
Globe,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
CircleCheckBig,
|
||||||
|
CircleDot,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { updateStreamStatus } from '@/lib/firestore-particles';
|
import { updateStreamStatus } from '@/lib/firestore-particles';
|
||||||
|
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
|
||||||
import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay';
|
import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay';
|
||||||
import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
|
import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
|
||||||
import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay';
|
import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay';
|
||||||
import { parseVisibleTo } from '@/lib/stream-visibility';
|
import { parseVisibleTo } from '@/lib/stream-visibility';
|
||||||
|
import { getParticleDisplayName } from '@/lib/particle-display';
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -47,32 +47,20 @@ import { resolveHumanDisplay } from '@/lib/humans';
|
|||||||
import { platform } from '@/lib/platform';
|
import { platform } from '@/lib/platform';
|
||||||
import { requireDesktop } from '@/lib/platform/desktop-only';
|
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 {
|
interface TopBarProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
particle: Particle | null;
|
particle: Particle | null;
|
||||||
streamParticle: Particle & { type: 'stream' };
|
streamParticle: Particle & { type: 'stream' };
|
||||||
|
/** Resolved path of the stream — may be nested under a container. */
|
||||||
|
streamPath: ParticlePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
export function TopBar({
|
||||||
const navigate = useNavigate();
|
networkId,
|
||||||
|
particle,
|
||||||
|
streamParticle,
|
||||||
|
streamPath,
|
||||||
|
}: TopBarProps) {
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
||||||
@@ -104,7 +92,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
<div className="drag-region flex flex-row px-2 gap-1 items-center">
|
<div className="drag-region flex flex-row px-2 gap-1 items-center">
|
||||||
<WindowControls />
|
<WindowControls />
|
||||||
|
|
||||||
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
|
<Breadcrumb className="no-drag bg-card/70 border-border mx-auto rounded-full border px-3 py-1 backdrop-blur-sm">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{streamParticle && (
|
{streamParticle && (
|
||||||
<>
|
<>
|
||||||
@@ -147,23 +135,26 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
return (
|
return (
|
||||||
<Tooltip key={humanId}>
|
<Tooltip key={humanId}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Avatar size="sm">
|
<HumanAvatar
|
||||||
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
size="sm"
|
||||||
{display.initials}
|
avatarObjectId={display.avatarObjectId}
|
||||||
</AvatarFallback>
|
initials={display.initials}
|
||||||
</Avatar>
|
fallbackClassName="bg-red-500/30 text-[8px] text-red-700 dark:text-red-200"
|
||||||
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{display.email}</TooltipContent>
|
<TooltipContent>{display.email}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
<span className="text-xs font-medium text-red-200">Join</span>
|
<span className="text-xs font-medium text-red-600 dark:text-red-200">
|
||||||
|
Join
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{streamParticle.status === 'closed' && (
|
{!isStreamOpen(streamParticle) && (
|
||||||
<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">
|
<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">
|
||||||
<CircleCheckBig className="size-3" />
|
<CircleCheckBig className="size-3" />
|
||||||
Closed
|
Closed
|
||||||
</span>
|
</span>
|
||||||
@@ -186,18 +177,22 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
{isCreator && (
|
||||||
|
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Rename stream
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={async () => {
|
onSelect={async () => {
|
||||||
const docPath = toFirestoreDocPath(
|
const docPath = toFirestoreDocPath(streamPath);
|
||||||
particlePath(networkId, [streamParticle.id]),
|
|
||||||
);
|
|
||||||
await updateStreamStatus(
|
await updateStreamStatus(
|
||||||
docPath,
|
docPath,
|
||||||
streamParticle.status === 'open' ? 'closed' : 'open',
|
isStreamOpen(streamParticle) ? 'closed' : 'open',
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{streamParticle.status === 'open' ? (
|
{isStreamOpen(streamParticle) ? (
|
||||||
<>
|
<>
|
||||||
<CircleCheckBig className="size-4" />
|
<CircleCheckBig className="size-4" />
|
||||||
Close stream
|
Close stream
|
||||||
@@ -209,12 +204,6 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isCreator && (
|
|
||||||
<DropdownMenuItem onSelect={() => setRenameOpen(true)}>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
Rename stream
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{canDeleteParticle && (
|
{canDeleteParticle && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => setDeleteOpen(true)}
|
onSelect={() => setDeleteOpen(true)}
|
||||||
@@ -224,16 +213,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
Delete particle
|
Delete particle
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onSelect={() => navigate('/settings')}>
|
|
||||||
<Settings className="size-4" />
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{renameOpen && isCreator && (
|
{renameOpen && isCreator && (
|
||||||
<RenameStreamOverlay
|
<RenameStreamOverlay
|
||||||
networkId={networkId}
|
streamPath={streamPath}
|
||||||
streamParticle={streamParticle}
|
streamParticle={streamParticle}
|
||||||
onClose={() => setRenameOpen(false)}
|
onClose={() => setRenameOpen(false)}
|
||||||
/>
|
/>
|
||||||
@@ -241,8 +226,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
|
|
||||||
{deleteOpen && canDeleteParticle && particle && userId && (
|
{deleteOpen && canDeleteParticle && particle && userId && (
|
||||||
<DeleteParticleOverlay
|
<DeleteParticleOverlay
|
||||||
networkId={networkId}
|
streamPath={streamPath}
|
||||||
streamId={streamParticle.id}
|
|
||||||
particle={particle}
|
particle={particle}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteOpen(false)}
|
||||||
@@ -252,6 +236,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
{membersOpen && (
|
{membersOpen && (
|
||||||
<StreamMembersOverlay
|
<StreamMembersOverlay
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
|
streamPath={streamPath}
|
||||||
streamParticle={streamParticle}
|
streamParticle={streamParticle}
|
||||||
isCreator={isCreator}
|
isCreator={isCreator}
|
||||||
onClose={() => setMembersOpen(false)}
|
onClose={() => setMembersOpen(false)}
|
||||||
@@ -297,31 +282,33 @@ function MembersIndicator({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{isNetworkWide ? (
|
{isNetworkWide ? (
|
||||||
<>
|
<>
|
||||||
<Globe className="size-3 text-white/50" />
|
<Globe className="text-muted-foreground size-3" />
|
||||||
<span>Everyone</span>
|
<span>Everyone</span>
|
||||||
</>
|
</>
|
||||||
) : isPrivate ? (
|
) : isPrivate ? (
|
||||||
<>
|
<>
|
||||||
<Lock className="size-3 text-white/50" />
|
<Lock className="text-muted-foreground size-3" />
|
||||||
<span>Private</span>
|
<span>Private</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AvatarGroup>
|
<AvatarGroup>
|
||||||
{shownMembers.map((human) => (
|
{shownMembers.map((human) => (
|
||||||
<Avatar key={human.id} size="sm">
|
<HumanAvatar
|
||||||
<AvatarFallback className="text-[8px]">
|
key={human.id}
|
||||||
{resolveHumanDisplay(human.id, humans).initials}
|
size="sm"
|
||||||
</AvatarFallback>
|
avatarObjectId={human.avatar_object_id}
|
||||||
</Avatar>
|
initials={resolveHumanDisplay(human.id, humans).initials}
|
||||||
|
fallbackClassName="text-[8px]"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<span className="text-white/50">+{overflow}</span>
|
<span className="text-muted-foreground">+{overflow}</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -355,9 +342,12 @@ function ParticleBreadcrumbContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
|
<HumanAvatar
|
||||||
<AvatarFallback>{display.initials}</AvatarFallback>
|
size="sm"
|
||||||
</Avatar>
|
className={isOnline ? 'ring-2 ring-green-500' : ''}
|
||||||
|
avatarObjectId={display.avatarObjectId}
|
||||||
|
initials={display.initials}
|
||||||
|
/>
|
||||||
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Play } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { isParticleDeleted, type Particle } from '@/api/types';
|
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||||
@@ -19,17 +20,20 @@ import {
|
|||||||
ComposeOverlay,
|
ComposeOverlay,
|
||||||
type ComposeStep,
|
type ComposeStep,
|
||||||
} from '@/features/compose/compose-overlay';
|
} from '@/features/compose/compose-overlay';
|
||||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
|
||||||
import { PlaybackPageIndicator } from '@/features/particles/playback-page-indicator';
|
|
||||||
import {
|
import {
|
||||||
MediaParticleView,
|
MediaParticleView,
|
||||||
type MediaParticleHandle,
|
type MediaParticleHandle,
|
||||||
} from '@/features/particles/media-particle-view';
|
} from '@/features/particles/media-particle-view';
|
||||||
import { TextParticleView } from '@/features/particles/text-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 { FallbackParticleView } from '@/features/particles/fallback-particle-view';
|
||||||
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
|
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
|
||||||
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
import {
|
||||||
import { KeyHint } from '@/components/key-hint';
|
BottomBar,
|
||||||
|
StreamViewControls,
|
||||||
|
} from '@/features/particles/stream-bottom-bar';
|
||||||
|
import { StreamListSidebar } from '@/features/particles/stream-list-sidebar';
|
||||||
|
import { useStreamViewMode } from '@/hooks/use-stream-view-mode';
|
||||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||||
import {
|
import {
|
||||||
KeybindingsOverlay,
|
KeybindingsOverlay,
|
||||||
@@ -51,7 +55,6 @@ import {
|
|||||||
type ComposingMode,
|
type ComposingMode,
|
||||||
} from '@/features/particles/stream-presence-context';
|
} from '@/features/particles/stream-presence-context';
|
||||||
import { ComposingIndicator } from '@/components/composing-indicator';
|
import { ComposingIndicator } from '@/components/composing-indicator';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { useMount } from 'react-use';
|
import { useMount } from 'react-use';
|
||||||
import {
|
import {
|
||||||
usePlaybackPauseStore,
|
usePlaybackPauseStore,
|
||||||
@@ -63,11 +66,29 @@ import { useStreamActionKeys } from '@/hooks/use-stream-action-keys';
|
|||||||
import { platform } from '@/lib/platform';
|
import { platform } from '@/lib/platform';
|
||||||
import { requireDesktop } from '@/lib/platform/desktop-only';
|
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(
|
function getReactions(
|
||||||
particle: Particle,
|
particle: Particle,
|
||||||
): Record<string, string[]> | undefined {
|
): Record<string, string[]> | undefined {
|
||||||
if (isParticleDeleted(particle)) return undefined;
|
if (isParticleDeleted(particle)) return undefined;
|
||||||
if (particle.type === 'media' || particle.type === 'text')
|
if (
|
||||||
|
particle.type === 'media' ||
|
||||||
|
particle.type === 'text' ||
|
||||||
|
particle.type === 'task'
|
||||||
|
)
|
||||||
return particle.reactions;
|
return particle.reactions;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -134,6 +155,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
|||||||
bindings: [
|
bindings: [
|
||||||
{ keys: ['←', '→', '↑', '↓'], description: 'Previous / next particle' },
|
{ keys: ['←', '→', '↑', '↓'], description: 'Previous / next particle' },
|
||||||
{ keys: ['Esc'], description: 'Back to network' },
|
{ keys: ['Esc'], description: 'Back to network' },
|
||||||
|
{ keys: ['L'], description: 'Toggle list view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -151,6 +173,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
|||||||
{ keys: ['Hold', '`'], description: 'Reply' },
|
{ keys: ['Hold', '`'], description: 'Reply' },
|
||||||
{ keys: ['S'], description: 'Screen record' },
|
{ keys: ['S'], description: 'Screen record' },
|
||||||
{ keys: ['T'], description: 'Text compose' },
|
{ keys: ['T'], description: 'Text compose' },
|
||||||
|
{ keys: ['D'], description: 'New task' },
|
||||||
{ keys: ['V'], description: 'Toggle video / audio' },
|
{ keys: ['V'], description: 'Toggle video / audio' },
|
||||||
{ keys: ['H'], description: 'Join huddle' },
|
{ keys: ['H'], description: 'Join huddle' },
|
||||||
],
|
],
|
||||||
@@ -189,6 +212,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
platform.autoplay.dismiss();
|
platform.autoplay.dismiss();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { mode, toggle: toggleViewMode } = useStreamViewMode();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
currentParticle,
|
currentParticle,
|
||||||
@@ -198,7 +223,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
prev,
|
prev,
|
||||||
goTo,
|
goTo,
|
||||||
goToParticle,
|
goToParticle,
|
||||||
} = useStreamPlayback(streamParticle, path);
|
} = useStreamPlayback(streamParticle, path, {
|
||||||
|
autoAdvanceOnNew: mode === 'player',
|
||||||
|
});
|
||||||
|
|
||||||
usePrefetchAdjacentMedia(children, currentIndex);
|
usePrefetchAdjacentMedia(children, currentIndex);
|
||||||
|
|
||||||
@@ -258,7 +285,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
[handleToggleReaction],
|
[handleToggleReaction],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { fastPlayback } = usePlaybackKeys({ mediaRef });
|
const { fastPlayback, spacePaused, resume } = usePlaybackKeys({ mediaRef });
|
||||||
|
|
||||||
const handleExitNavigate = useCallback(() => {
|
const handleExitNavigate = useCallback(() => {
|
||||||
navigate(`/${networkId}`);
|
navigate(`/${networkId}`);
|
||||||
@@ -271,6 +298,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
childrenLength: children.length,
|
childrenLength: children.length,
|
||||||
mediaRef,
|
mediaRef,
|
||||||
onExit: handleExitNavigate,
|
onExit: handleExitNavigate,
|
||||||
|
onToggleViewMode: toggleViewMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenHuddle = useCallback(() => {
|
const handleOpenHuddle = useCallback(() => {
|
||||||
@@ -301,16 +329,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
// Broadcast composing state to other viewers
|
// Broadcast composing state to other viewers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stepToMode: Record<string, ComposingMode | null> = {
|
const mode = STEP_TO_COMPOSING_MODE[composeStep] ?? null;
|
||||||
idle: null,
|
|
||||||
submitting: null,
|
|
||||||
recording: 'recording',
|
|
||||||
typing: 'typing',
|
|
||||||
reviewing: 'typing',
|
|
||||||
configuring: 'typing',
|
|
||||||
picking: 'screen',
|
|
||||||
};
|
|
||||||
const mode = stepToMode[composeStep] ?? null;
|
|
||||||
if (mode) {
|
if (mode) {
|
||||||
startComposing(mode);
|
startComposing(mode);
|
||||||
} else {
|
} else {
|
||||||
@@ -331,7 +350,12 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
// Always show controls when compose is active or exit countdown is visible
|
// Always show controls when compose is active or exit countdown is visible
|
||||||
const controlsVisible = showControls || composeActive || status === 'ended';
|
const controlsVisible = showControls || composeActive || status === 'ended';
|
||||||
|
|
||||||
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
|
// No auto-exit while browsing in list mode.
|
||||||
|
const exitRemainingMs = useExitCountdown(
|
||||||
|
status,
|
||||||
|
paused || mode === 'list',
|
||||||
|
handleExitNavigate,
|
||||||
|
);
|
||||||
|
|
||||||
// Reset progress when the particle changes.
|
// Reset progress when the particle changes.
|
||||||
if (currentParticle?.id !== prevParticleId) {
|
if (currentParticle?.id !== prevParticleId) {
|
||||||
@@ -357,7 +381,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
|
<div className="bg-background flex h-full flex-col items-center justify-center gap-4">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
No particles in this stream yet
|
No particles in this stream yet
|
||||||
</p>
|
</p>
|
||||||
@@ -371,14 +395,15 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
targetPath={path}
|
targetPath={path}
|
||||||
onActiveChange={setComposeActive}
|
onActiveChange={setComposeActive}
|
||||||
disabled={streamParticle.status === 'closed'}
|
|
||||||
onParticleCreated={handleParticleCreated}
|
onParticleCreated={handleParticleCreated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render particle content inline
|
// Render particle content inline. In list mode the selected particle still
|
||||||
|
// plays, but nothing chains: reaching the end doesn't advance.
|
||||||
|
const handleParticleEnded = mode === 'player' ? next : noop;
|
||||||
function renderParticle(particle: Particle) {
|
function renderParticle(particle: Particle) {
|
||||||
if (isParticleDeleted(particle)) {
|
if (isParticleDeleted(particle)) {
|
||||||
return (
|
return (
|
||||||
@@ -387,7 +412,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
particle={particle}
|
particle={particle}
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={next}
|
onEnded={handleParticleEnded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -400,7 +425,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
particle={particle}
|
particle={particle}
|
||||||
streamPath={path}
|
streamPath={path}
|
||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={next}
|
onEnded={handleParticleEnded}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -411,10 +436,22 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
particle={particle}
|
particle={particle}
|
||||||
streamPath={path}
|
streamPath={path}
|
||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={next}
|
onEnded={handleParticleEnded}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'task':
|
||||||
|
return (
|
||||||
|
<TaskParticleView
|
||||||
|
key={particle.id}
|
||||||
|
particle={particle}
|
||||||
|
containerPath={path}
|
||||||
|
paused={paused}
|
||||||
|
onEnded={handleParticleEnded}
|
||||||
|
onProgress={setProgress}
|
||||||
|
immersive
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<FallbackParticleView particle={particle} networkId={networkId} />
|
<FallbackParticleView particle={particle} networkId={networkId} />
|
||||||
@@ -423,233 +460,122 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="bg-background flex h-screen overflow-hidden">
|
||||||
className="relative flex h-screen flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
|
{/* Stream chrome — immersive playback column */}
|
||||||
onMouseMove={handleMouseActivity}
|
<div
|
||||||
onMouseLeave={() => setShowControls(false)}
|
className="bg-background relative flex min-w-0 flex-1 flex-col overflow-hidden [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
|
||||||
>
|
onMouseMove={handleMouseActivity}
|
||||||
{/* Top gradient safe zone */}
|
onMouseLeave={() => setShowControls(false)}
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
|
>
|
||||||
|
{/* 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" />
|
||||||
|
|
||||||
{/* TopBar — always visible */}
|
{/* TopBar — always visible */}
|
||||||
<div className="z-10 absolute left-0 right-0 pt-2">
|
<div className="z-10 absolute left-0 right-0 pt-2">
|
||||||
<TopBar
|
<TopBar
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
particle={currentParticle}
|
particle={currentParticle}
|
||||||
streamParticle={streamParticle}
|
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="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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Composing indicator — left edge, always visible */}
|
{/* Main playback area */}
|
||||||
<ComposingIndicator
|
<div className="flex-1 overflow-hidden">
|
||||||
users={composingUsers}
|
{currentParticle && (
|
||||||
networkHumans={network?.humans}
|
<div className="relative flex h-full w-full cursor-pointer items-center justify-center">
|
||||||
/>
|
{renderParticle(currentParticle)}
|
||||||
|
|
||||||
<ComposeOverlay
|
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
|
||||||
networkId={networkId}
|
{fastPlayback && (
|
||||||
targetPath={path}
|
<div className="bg-card/70 text-foreground border-border rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
|
||||||
onActiveChange={setComposeActive}
|
1.5x
|
||||||
onStepChange={setComposeStep}
|
</div>
|
||||||
disabled={streamParticle.status === 'closed'}
|
)}
|
||||||
onParticleCreated={handleParticleCreated}
|
{spacePaused && (
|
||||||
/>
|
<button
|
||||||
|
type="button"
|
||||||
{/* Bottom gradient safe zone for keyboard hints */}
|
onClick={resume}
|
||||||
<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" />
|
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"
|
||||||
{/* BottomBar */}
|
>
|
||||||
<BottomBar
|
<Play className="size-3 fill-current" />
|
||||||
visible={controlsVisible}
|
Paused
|
||||||
total={children.length}
|
</button>
|
||||||
current={currentIndex}
|
)}
|
||||||
progress={progress}
|
</div>
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<StreamViewControls
|
|
||||||
showEscape
|
|
||||||
onOpenKeybindings={onOpenKeybindings}
|
|
||||||
onOpenHuddle={onOpenHuddle}
|
|
||||||
onExit={onExit}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StreamViewControls({
|
{/* Reaction bar — always visible */}
|
||||||
showEscape,
|
{currentParticle && !isParticleDeleted(currentParticle) && (
|
||||||
onOpenKeybindings,
|
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
|
||||||
onOpenHuddle,
|
<ReactionBar
|
||||||
onExit,
|
reactions={getReactions(currentParticle)}
|
||||||
}: {
|
currentHumanId={authedUser?.id ?? ''}
|
||||||
showEscape?: boolean;
|
humans={network?.humans}
|
||||||
onOpenKeybindings: () => void;
|
onToggle={handleToggleReaction}
|
||||||
onOpenHuddle: () => void;
|
onOpenTextReaction={() => setTextReactionOpen(true)}
|
||||||
onExit: () => void;
|
/>
|
||||||
}) {
|
<TextReactionInput
|
||||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
open={textReactionOpen}
|
||||||
return (
|
onSubmit={handleSubmitTextReaction}
|
||||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
onClose={() => setTextReactionOpen(false)}
|
||||||
{showEscape && (
|
/>
|
||||||
<KeyHint
|
</div>
|
||||||
keys="Esc"
|
)}
|
||||||
onClick={onExit}
|
|
||||||
title="Back to network (or press Esc)"
|
{/* Composing indicator — left edge, always visible */}
|
||||||
>
|
<ComposingIndicator
|
||||||
back
|
users={composingUsers}
|
||||||
</KeyHint>
|
networkHumans={network?.humans}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComposeOverlay
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Browse sidebar — a separate chat-like panel beside the stream */}
|
||||||
|
{mode === 'list' && (
|
||||||
|
<StreamListSidebar
|
||||||
|
items={children}
|
||||||
|
networkId={networkId}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
onSelect={goTo}
|
||||||
|
onToggle={toggleViewMode}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
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 { useEffect, useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
import type { ParticlePath } from '@/lib/particle-path';
|
import type { ParticlePath } from '@/lib/particle-path';
|
||||||
@@ -8,8 +8,11 @@ import {
|
|||||||
type LinkPreviewEntry,
|
type LinkPreviewEntry,
|
||||||
} from '@/hooks/use-link-metadata';
|
} from '@/hooks/use-link-metadata';
|
||||||
import { extractUrls } from '@/lib/link-metadata';
|
import { extractUrls } from '@/lib/link-metadata';
|
||||||
|
import { getImmersiveTextStyle } from '@/lib/immersive-text';
|
||||||
|
import { hasMarkdownFormatting } from '@/lib/markdown';
|
||||||
import {
|
import {
|
||||||
LinkPreviewCard,
|
LinkPreviewCard,
|
||||||
|
LinkPreviewCardFallback,
|
||||||
LinkPreviewCardSkeleton,
|
LinkPreviewCardSkeleton,
|
||||||
} from '@/components/link-preview-card';
|
} from '@/components/link-preview-card';
|
||||||
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
||||||
@@ -17,6 +20,7 @@ import { ParticleAttachments } from '@/features/particles/particle-attachments';
|
|||||||
import { TextEditOverlay } from '@/features/particles/text-edit-overlay';
|
import { TextEditOverlay } from '@/features/particles/text-edit-overlay';
|
||||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useFixedDwell } from '@/hooks/use-fixed-dwell';
|
||||||
import { MarkdownEditor } from '@/features/compose/markdown-editor';
|
import { MarkdownEditor } from '@/features/compose/markdown-editor';
|
||||||
|
|
||||||
type TextParticle = Extract<Particle, { type: 'text' }>;
|
type TextParticle = Extract<Particle, { type: 'text' }>;
|
||||||
@@ -33,7 +37,6 @@ interface TextParticleViewProps {
|
|||||||
const CHARS_PER_MINUTE = 1000;
|
const CHARS_PER_MINUTE = 1000;
|
||||||
const MIN_DURATION_S = 3;
|
const MIN_DURATION_S = 3;
|
||||||
const MAX_DURATION_S = 15;
|
const MAX_DURATION_S = 15;
|
||||||
const TICK_MS = 100;
|
|
||||||
const EXTRA_S_PER_LINK = 2;
|
const EXTRA_S_PER_LINK = 2;
|
||||||
const EXTRA_S_PER_ATTACHMENT = 2;
|
const EXTRA_S_PER_ATTACHMENT = 2;
|
||||||
|
|
||||||
@@ -51,18 +54,6 @@ function computeReadDuration(
|
|||||||
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
|
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[] }) {
|
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
@@ -72,7 +63,9 @@ function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
|||||||
<LinkPreviewCardSkeleton />
|
<LinkPreviewCardSkeleton />
|
||||||
) : entry.metadata ? (
|
) : entry.metadata ? (
|
||||||
<LinkPreviewCard metadata={entry.metadata} />
|
<LinkPreviewCard metadata={entry.metadata} />
|
||||||
) : null}
|
) : (
|
||||||
|
<LinkPreviewCardFallback url={entry.url} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -104,29 +97,14 @@ export function TextParticleView({
|
|||||||
urls.length,
|
urls.length,
|
||||||
attachments.length,
|
attachments.length,
|
||||||
);
|
);
|
||||||
const elapsedRef = useRef(0);
|
|
||||||
|
|
||||||
// Reset elapsed when particle changes
|
useFixedDwell({
|
||||||
useEffect(() => {
|
id: particle.id,
|
||||||
elapsedRef.current = 0;
|
durationS,
|
||||||
}, [particle.id]);
|
paused,
|
||||||
|
onEnded,
|
||||||
useEffect(() => {
|
onProgress,
|
||||||
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
|
// Content is just bare URLs with no surrounding text
|
||||||
const contentTrimmed = content.trim();
|
const contentTrimmed = content.trim();
|
||||||
@@ -144,7 +122,7 @@ export function TextParticleView({
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
Edit
|
Edit
|
||||||
@@ -152,7 +130,7 @@ export function TextParticleView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const editedLabel = particle.properties.edited_at && (
|
const editedLabel = particle.properties.edited_at && (
|
||||||
<span className="text-xs text-white/40">
|
<span className="text-muted-foreground text-xs">
|
||||||
edited <RelativeTimestamp date={particle.properties.edited_at} />
|
edited <RelativeTimestamp date={particle.properties.edited_at} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -168,7 +146,7 @@ export function TextParticleView({
|
|||||||
// Mode 1: bare URLs only — show link cards centered
|
// Mode 1: bare URLs only — show link cards centered
|
||||||
if (linksOnly && !hasAttachments) {
|
if (linksOnly && !hasAttachments) {
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
<div className="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)]">
|
||||||
<LinkPreviews entries={linkPreviews} />
|
<LinkPreviews entries={linkPreviews} />
|
||||||
{editedLabel && (
|
{editedLabel && (
|
||||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
|
||||||
@@ -189,10 +167,10 @@ export function TextParticleView({
|
|||||||
) {
|
) {
|
||||||
const style = getImmersiveTextStyle(content.length);
|
const style = getImmersiveTextStyle(content.length);
|
||||||
return (
|
return (
|
||||||
<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)]">
|
<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)]">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text',
|
'text-foreground max-w-2xl cursor-text select-text break-words text-center leading-relaxed',
|
||||||
style.size,
|
style.size,
|
||||||
style.weight,
|
style.weight,
|
||||||
)}
|
)}
|
||||||
@@ -208,18 +186,8 @@ export function TextParticleView({
|
|||||||
|
|
||||||
// Mode 3: card layout
|
// Mode 3: card layout
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
<div className="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
|
<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)]">
|
||||||
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
|
<MarkdownEditor
|
||||||
key={content}
|
key={content}
|
||||||
value={content}
|
value={content}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function TextReactionInput({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -74,12 +74,14 @@ export function TextReactionInput({
|
|||||||
}}
|
}}
|
||||||
placeholder="Quick reply…"
|
placeholder="Quick reply…"
|
||||||
maxLength={MAX_LENGTH}
|
maxLength={MAX_LENGTH}
|
||||||
className="w-24 bg-transparent text-sm text-white outline-none placeholder:text-white/40"
|
className="text-foreground placeholder:text-muted-foreground w-24 bg-transparent text-sm outline-none"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-[1.5ch] text-right text-[10px] tabular-nums',
|
'min-w-[1.5ch] text-right text-[10px] tabular-nums',
|
||||||
remaining <= 8 ? 'text-amber-300/80' : 'text-white/30',
|
remaining <= 8
|
||||||
|
? 'text-amber-600 dark:text-amber-400'
|
||||||
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{remaining}
|
{remaining}
|
||||||
@@ -91,8 +93,8 @@ export function TextReactionInput({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
|
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
|
||||||
canSubmit
|
canSubmit
|
||||||
? 'bg-white/20 text-white hover:bg-white/30'
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
: 'text-white/30',
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
aria-label="Send reaction"
|
aria-label="Send reaction"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Volume2,
|
Volume2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Camera,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Monitor,
|
||||||
|
Palette,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
|
import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import { WindowControls } from '@/components/window-controls';
|
import { WindowControls } from '@/components/window-controls';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Muted } from '@/components/ui/typography';
|
import { Muted } from '@/components/ui/typography';
|
||||||
@@ -22,6 +29,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { CopyableEmail } from '@/components/copyable-email';
|
import { CopyableEmail } from '@/components/copyable-email';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useSoundEffectsStore } from '@/stores/sound-effects-store';
|
import { useSoundEffectsStore } from '@/stores/sound-effects-store';
|
||||||
|
import { useThemeStore, type ThemeMode } from '@/stores/theme-store';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { logError, toUserMessage } from '@/lib/errors';
|
import { logError, toUserMessage } from '@/lib/errors';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -78,6 +86,50 @@ 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() {
|
export default function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
@@ -88,6 +140,7 @@ export default function SettingsPage() {
|
|||||||
const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled);
|
const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled);
|
||||||
const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled);
|
const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled);
|
||||||
const [version, setVersion] = useState<string>();
|
const [version, setVersion] = useState<string>();
|
||||||
|
const [avatarOpen, setAvatarOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.app.getVersion().then(setVersion);
|
platform.app.getVersion().then(setVersion);
|
||||||
@@ -135,19 +188,39 @@ export default function SettingsPage() {
|
|||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{/* Profile header */}
|
{/* Profile header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-5">
|
<div className="flex items-center gap-3 px-4 py-5">
|
||||||
<Avatar size="lg">
|
<button
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
type="button"
|
||||||
{initials}
|
onClick={() => setAvatarOpen(true)}
|
||||||
</AvatarFallback>
|
className="group relative rounded-full"
|
||||||
</Avatar>
|
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>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{user?.email_prefix}</p>
|
<p className="truncate text-sm font-medium">{user?.email_prefix}</p>
|
||||||
<Muted className="text-xs">{user?.email}</Muted>
|
<Muted className="text-xs">{user?.email}</Muted>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AvatarEditDialog open={avatarOpen} onOpenChange={setAvatarOpen} />
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
<SettingsGroup title="Appearance">
|
||||||
|
<ThemeSetting />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
|
||||||
<SettingsGroup title="Notifications">
|
<SettingsGroup title="Notifications">
|
||||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||||
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Camera, Loader2, Trash2, Upload } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Muted } from '@/components/ui/typography';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
||||||
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
|
import { useFileInput } from '@/hooks/use-file-input';
|
||||||
|
import { toAvatarBlob } from '@/lib/avatar-image';
|
||||||
|
import { logError, toUserMessage } from '@/lib/errors';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Guard the *source* file before decoding so we never load a huge image into
|
||||||
|
// memory just to throw most of it away — the uploaded blob is always our small
|
||||||
|
// re-encoded square regardless of input size.
|
||||||
|
const MAX_SOURCE_BYTES = 30 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface AvatarEditDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: AvatarEditDialogProps) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const refreshUser = useAuthStore((s) => s.refreshUser);
|
||||||
|
const currentUrl = useAvatarUrl(user?.avatar_object_id);
|
||||||
|
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?';
|
||||||
|
const hasAvatar = !!user?.avatar_object_id;
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<'upload' | 'camera'>('upload');
|
||||||
|
const [prepared, setPrepared] = useState<{ blob: Blob; url: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Keep the latest prepared blob in a ref so the unmount cleanup can revoke
|
||||||
|
// its object URL without re-running on every change.
|
||||||
|
const preparedRef = useRef(prepared);
|
||||||
|
useEffect(() => {
|
||||||
|
preparedRef.current = prepared;
|
||||||
|
}, [prepared]);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (preparedRef.current) URL.revokeObjectURL(preparedRef.current.url);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPreparedFromBlob = useCallback((blob: Blob) => {
|
||||||
|
setPrepared((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev.url);
|
||||||
|
return { blob, url: URL.createObjectURL(blob) };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close and reset to a clean slate so reopening starts fresh.
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setPrepared((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev.url);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setTab('upload');
|
||||||
|
setBusy(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
if (next) onOpenChange(true);
|
||||||
|
else close();
|
||||||
|
},
|
||||||
|
[close, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error('Please choose an image file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_SOURCE_BYTES) {
|
||||||
|
toast.error('That image is too large — choose one under 30MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const blob = await toAvatarBlob(bitmap);
|
||||||
|
bitmap.close();
|
||||||
|
setPreparedFromBlob(blob);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Could not process that image.');
|
||||||
|
logError(err, { scope: 'avatar.processFile' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setPreparedFromBlob],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { openFilePicker, isDragging, dropZoneProps } = useFileInput({
|
||||||
|
onFilesSelected: handleFiles,
|
||||||
|
enabled: open && tab === 'upload',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!prepared) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiClient.updateAvatar(prepared.blob);
|
||||||
|
await refreshUser();
|
||||||
|
toast.success('Avatar updated');
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
logError(err, { scope: 'avatar.save' });
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiClient.deleteAvatar();
|
||||||
|
await refreshUser();
|
||||||
|
toast.success('Avatar removed');
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
logError(err, { scope: 'avatar.remove' });
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewUrl = prepared?.url ?? currentUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Profile picture</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload an image or take one with your camera.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-4 py-1">
|
||||||
|
<div className="bg-muted flex size-24 items-center justify-center overflow-hidden rounded-full border">
|
||||||
|
{previewUrl ? (
|
||||||
|
<img src={previewUrl} alt="" className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-2xl font-medium">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(v as 'upload' | 'camera')}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="upload">
|
||||||
|
<Upload />
|
||||||
|
Upload
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="camera">
|
||||||
|
<Camera />
|
||||||
|
Take photo
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="upload" className="pt-3">
|
||||||
|
<div
|
||||||
|
{...dropZoneProps}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-5 text-center transition-colors',
|
||||||
|
isDragging && 'border-primary bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Muted className="text-xs">Drag an image here, or</Muted>
|
||||||
|
<Button variant="outline" size="sm" onClick={openFilePicker}>
|
||||||
|
Choose image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="camera" className="pt-3">
|
||||||
|
<CameraCapture
|
||||||
|
active={open && tab === 'camera'}
|
||||||
|
onCapture={setPreparedFromBlob}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{hasAvatar && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive sm:mr-auto"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 size-3.5" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={close} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
|
||||||
|
{busy && <Loader2 className="mr-1 size-3.5 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CameraCapture({
|
||||||
|
active,
|
||||||
|
onCapture,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onCapture: (blob: Blob) => void;
|
||||||
|
}) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [capturing, setCapturing] = useState(false);
|
||||||
|
// Honor the camera the user picked in Audio & Video settings. `ideal` rather
|
||||||
|
// than `exact` so a since-unplugged device falls back to the default instead
|
||||||
|
// of throwing OverconstrainedError.
|
||||||
|
const savedCameraId = useMediaDevicesStore((s) => s.camera?.deviceId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let acquired: MediaStream | null = null;
|
||||||
|
|
||||||
|
const video: MediaTrackConstraints = { aspectRatio: { ideal: 1 } };
|
||||||
|
if (savedCameraId) video.deviceId = { ideal: savedCameraId };
|
||||||
|
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({ video, audio: false })
|
||||||
|
.then((s) => {
|
||||||
|
if (cancelled) {
|
||||||
|
s.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acquired = s;
|
||||||
|
setStream(s);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : 'Unable to access camera',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
acquired?.getTracks().forEach((t) => t.stop());
|
||||||
|
setStream(null);
|
||||||
|
};
|
||||||
|
}, [active, savedCameraId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) videoRef.current.srcObject = stream;
|
||||||
|
}, [stream]);
|
||||||
|
|
||||||
|
const handleCapture = async () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
// readyState < HAVE_CURRENT_DATA (or zero dimensions) means no frame has
|
||||||
|
// decoded yet — capturing now would grab a blank image.
|
||||||
|
if (!video || video.readyState < 2 || !video.videoWidth) {
|
||||||
|
toast.error('Camera is still starting — try again in a moment.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapturing(true);
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(video);
|
||||||
|
// Mirror to match the (mirrored) live preview the user is looking at.
|
||||||
|
const blob = await toAvatarBlob(bitmap, { mirror: true });
|
||||||
|
bitmap.close();
|
||||||
|
onCapture(blob);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Could not capture photo.');
|
||||||
|
logError(err, { scope: 'avatar.capture' });
|
||||||
|
} finally {
|
||||||
|
setCapturing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1 rounded-lg border border-dashed px-6 py-5 text-center">
|
||||||
|
<Muted className="text-xs">Couldn't access your camera.</Muted>
|
||||||
|
<Muted className="text-[11px]">{error}</Muted>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="bg-muted size-36 overflow-hidden rounded-full border">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
className="size-full -scale-x-100 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleCapture} disabled={!stream || capturing}>
|
||||||
|
<Camera className="mr-1 size-3.5" />
|
||||||
|
Capture
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery, skipToken } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an avatar object id to a signed download URL. Mirrors
|
||||||
|
* {@link import('./use-download-url').useDownloadUrl} — React Query handles
|
||||||
|
* caching and de-duping, so many avatars sharing an id make a single request.
|
||||||
|
*/
|
||||||
|
export function useAvatarUrl(
|
||||||
|
objectId: string | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['avatar-url', objectId],
|
||||||
|
queryFn: objectId
|
||||||
|
? () => apiClient.getAvatarDownloadUrl(objectId)
|
||||||
|
: skipToken,
|
||||||
|
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { where } from 'firebase/firestore';
|
||||||
|
import { useLiveParticleChildren } from '@/hooks/use-particle';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
||||||
|
import type { Particle } from '@/api/types';
|
||||||
|
|
||||||
|
const INITIAL_PAGE_SIZE = 9;
|
||||||
|
const PAGE_INCREMENT = 9;
|
||||||
|
|
||||||
|
// Stable references so the Firestore subscriptions don't re-attach per render.
|
||||||
|
const OPEN_STATUS_FILTER = where('status', '==', 'open');
|
||||||
|
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
|
||||||
|
const CONTAINER_TYPE_FILTER = where('type', 'in', ['stream', 'folder']);
|
||||||
|
const LEAF_TYPE_FILTER = where('type', 'in', [
|
||||||
|
'media',
|
||||||
|
'file',
|
||||||
|
'text',
|
||||||
|
'task',
|
||||||
|
'paper',
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface UseContainerChildrenOptions {
|
||||||
|
/**
|
||||||
|
* When set, list only streams with this status, filtered server-side like
|
||||||
|
* the pre-folder query (reuses its composite indexes). Used by the network
|
||||||
|
* root while folders are shelved; folders keep the full mixed-type behavior
|
||||||
|
* so the container model can be picked back up later. Only streams carry
|
||||||
|
* `status`, so the filter excludes other types by itself.
|
||||||
|
*/
|
||||||
|
streamStatus?: 'open' | 'closed';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseContainerChildrenResult {
|
||||||
|
/** All visible children, sorted by latest activity (then creation). */
|
||||||
|
items: Particle[];
|
||||||
|
isLoading: boolean;
|
||||||
|
canLoadMore: boolean;
|
||||||
|
loadMore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityTime(particle: Particle): number {
|
||||||
|
if (
|
||||||
|
(particle.type === 'stream' || particle.type === 'folder') &&
|
||||||
|
particle.last_child_created_at
|
||||||
|
) {
|
||||||
|
return particle.last_child_created_at.getTime();
|
||||||
|
}
|
||||||
|
return particle.created_at.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Children of a container (network root or folder), all particle types.
|
||||||
|
*
|
||||||
|
* Two merged subscriptions because visibility scoping filters on `visible_to`
|
||||||
|
* with array-contains-any, and leaf particles don't carry that field — a
|
||||||
|
* single scoped query would silently exclude them. Containers (streams,
|
||||||
|
* folders) are visibility-scoped; leaves inherit access from their container.
|
||||||
|
*/
|
||||||
|
export function useContainerChildren(
|
||||||
|
path: ParticlePath,
|
||||||
|
{ streamStatus }: UseContainerChildrenOptions = {},
|
||||||
|
): UseContainerChildrenResult {
|
||||||
|
const { networkId } = parseParticlePath(path);
|
||||||
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
|
const visibilityScopes = useMemo(() => {
|
||||||
|
const scopes: string[] = [];
|
||||||
|
if (userId) scopes.push(`human:${userId}`);
|
||||||
|
scopes.push(`network:${networkId}`);
|
||||||
|
return scopes;
|
||||||
|
}, [userId, networkId]);
|
||||||
|
|
||||||
|
const [limit, setLimit] = useState(INITIAL_PAGE_SIZE);
|
||||||
|
|
||||||
|
const containerFilter = streamStatus
|
||||||
|
? streamStatus === 'open'
|
||||||
|
? OPEN_STATUS_FILTER
|
||||||
|
: CLOSED_STATUS_FILTER
|
||||||
|
: CONTAINER_TYPE_FILTER;
|
||||||
|
|
||||||
|
const { children: containers, isLoading: containersLoading } =
|
||||||
|
useLiveParticleChildren(path, {
|
||||||
|
orderByField: 'last_child_created_at',
|
||||||
|
orderDirection: 'desc',
|
||||||
|
visibilityScopes,
|
||||||
|
whereFilter: containerFilter,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Passing undefined disables the subscription entirely in streams-only mode.
|
||||||
|
const { children: leaves, isLoading: leavesLoading } =
|
||||||
|
useLiveParticleChildren(streamStatus ? undefined : path, {
|
||||||
|
orderByField: 'created_at',
|
||||||
|
orderDirection: 'desc',
|
||||||
|
whereFilter: LEAF_TYPE_FILTER,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
[...containers, ...leaves].sort(
|
||||||
|
(a, b) => activityTime(b) - activityTime(a),
|
||||||
|
),
|
||||||
|
[containers, leaves],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Heuristic: a query returning a full page may have more behind it.
|
||||||
|
const canLoadMore = containers.length >= limit || leaves.length >= limit;
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
setLimit((prev) => prev + PAGE_INCREMENT);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
isLoading: containersLoading || leavesLoading,
|
||||||
|
canLoadMore,
|
||||||
|
loadMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
|||||||
type: T;
|
type: T;
|
||||||
properties: ParticlePropertiesMap[T];
|
properties: ParticlePropertiesMap[T];
|
||||||
createdByHumanId: string;
|
createdByHumanId: string;
|
||||||
|
// Required for container types
|
||||||
|
visibleTo?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateParticle() {
|
export function useCreateParticle() {
|
||||||
@@ -59,6 +61,7 @@ export function useCreateParticle() {
|
|||||||
params.type,
|
params.type,
|
||||||
params.properties,
|
params.properties,
|
||||||
params.createdByHumanId,
|
params.createdByHumanId,
|
||||||
|
params.visibleTo,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!CONTAINER_TYPES.has(params.type)) {
|
if (!CONTAINER_TYPES.has(params.type)) {
|
||||||
@@ -76,15 +79,17 @@ type CreateStreamParticleParams = {
|
|||||||
properties: ParticlePropertiesMap['stream'];
|
properties: ParticlePropertiesMap['stream'];
|
||||||
createdByHumanId: string;
|
createdByHumanId: string;
|
||||||
visibleTo?: string[];
|
visibleTo?: string[];
|
||||||
|
// Container to create the stream in; defaults to the network root.
|
||||||
|
parentPath?: ParticlePath;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCreateStreamParticle() {
|
export function useCreateStreamParticle() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (params: CreateStreamParticleParams) => {
|
mutationFn: async (params: CreateStreamParticleParams) => {
|
||||||
const path = particlePath(params.networkId, []);
|
const path = params.parentPath ?? particlePath(params.networkId, []);
|
||||||
const networkCollectionPath = toFirestoreChildrenPath(path);
|
const collectionPath = toFirestoreChildrenPath(path);
|
||||||
return await createStreamParticle(
|
return await createStreamParticle(
|
||||||
networkCollectionPath,
|
collectionPath,
|
||||||
params.properties,
|
params.properties,
|
||||||
params.createdByHumanId,
|
params.createdByHumanId,
|
||||||
params.visibleTo,
|
params.visibleTo,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { transferFiles } from '@/lib/data-transfer';
|
||||||
|
|
||||||
interface UseFileInputOptions {
|
interface UseFileInputOptions {
|
||||||
onFilesSelected: (files: File[]) => void;
|
onFilesSelected: (files: File[]) => void;
|
||||||
@@ -48,16 +49,27 @@ export function useFileInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
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 handlePaste = (e: ClipboardEvent) => {
|
||||||
const files = Array.from(e.clipboardData?.files ?? []);
|
const data = e.clipboardData;
|
||||||
if (files.length > 0) {
|
if (!data) return;
|
||||||
e.preventDefault();
|
|
||||||
onFilesRef.current(files);
|
// 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('paste', handlePaste);
|
window.addEventListener('paste', handlePaste, true);
|
||||||
return () => window.removeEventListener('paste', handlePaste);
|
return () => window.removeEventListener('paste', handlePaste, true);
|
||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Drag and drop handlers
|
||||||
@@ -99,7 +111,8 @@ export function useFileInput({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dragCountRef.current = 0;
|
dragCountRef.current = 0;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
const files = Array.from(e.dataTransfer.files);
|
|
||||||
|
const files = transferFiles(e.dataTransfer);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
onFilesRef.current(files);
|
onFilesRef.current(files);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const TICK_MS = 100;
|
||||||
|
|
||||||
|
interface UseFixedDwellOptions {
|
||||||
|
/** Reset key — restarts the timer when it changes (particle id). */
|
||||||
|
id: string;
|
||||||
|
durationS: number;
|
||||||
|
paused: boolean;
|
||||||
|
onEnded: () => void;
|
||||||
|
onProgress?: (ratio: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives a fixed-duration dwell timer for particles without intrinsic
|
||||||
|
* playback (text, tasks, events): reports progress and fires onEnded once
|
||||||
|
* the duration elapses. Pausing freezes elapsed time rather than resetting.
|
||||||
|
*/
|
||||||
|
export function useFixedDwell({
|
||||||
|
id,
|
||||||
|
durationS,
|
||||||
|
paused,
|
||||||
|
onEnded,
|
||||||
|
onProgress,
|
||||||
|
}: UseFixedDwellOptions): void {
|
||||||
|
const elapsedRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
elapsedRef.current = 0;
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
elapsedRef.current += TICK_MS / 1000;
|
||||||
|
const ratio = Math.min(elapsedRef.current / durationS, 1);
|
||||||
|
onProgress?.(ratio);
|
||||||
|
|
||||||
|
if (ratio >= 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
onEnded();
|
||||||
|
}
|
||||||
|
}, TICK_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [paused, durationS, onEnded, onProgress, id]);
|
||||||
|
}
|
||||||
+25
-21
@@ -1,29 +1,33 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||||
|
|
||||||
interface UseStreamKeyboardNavOptions {
|
interface UseListKeyboardNavOptions {
|
||||||
streams: Array<{ id: string }>;
|
items: Array<{ id: string }>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onNavigate: (streamId: string) => void;
|
onOpen: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStreamKeyboardNav({
|
/**
|
||||||
streams,
|
* Shared keyboard grammar for browsable lists (network root, folders):
|
||||||
|
* ↑/↓ move selection, Enter opens, 1–9 jump, V toggles video/audio.
|
||||||
|
*/
|
||||||
|
export function useListKeyboardNav({
|
||||||
|
items,
|
||||||
enabled,
|
enabled,
|
||||||
onNavigate,
|
onOpen,
|
||||||
}: UseStreamKeyboardNavOptions) {
|
}: UseListKeyboardNavOptions) {
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(
|
||||||
streams.length > 0 ? 0 : null,
|
items.length > 0 ? 0 : null,
|
||||||
);
|
);
|
||||||
const [prevStreamCount, setPrevStreamCount] = useState(streams.length);
|
const [prevItemCount, setPrevItemCount] = useState(items.length);
|
||||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||||
|
|
||||||
// Select the first stream once they load and clear when empty — but not on
|
// Select the first item once they load and clear when empty — but not on
|
||||||
// every Firestore update, which would scroll the list back to the top.
|
// every Firestore update, which would scroll the list back to the top.
|
||||||
if (streams.length !== prevStreamCount) {
|
if (items.length !== prevItemCount) {
|
||||||
setPrevStreamCount(streams.length);
|
setPrevItemCount(items.length);
|
||||||
if (streams.length === 0) {
|
if (items.length === 0) {
|
||||||
setSelectedIndex(null);
|
setSelectedIndex(null);
|
||||||
} else if (selectedIndex === null) {
|
} else if (selectedIndex === null) {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
@@ -31,7 +35,7 @@ export function useStreamKeyboardNav({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || streams.length === 0) return;
|
if (!enabled || items.length === 0) return;
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.defaultPrevented) return;
|
if (e.defaultPrevented) return;
|
||||||
@@ -49,19 +53,19 @@ export function useStreamKeyboardNav({
|
|||||||
const digit = parseInt(e.key, 10);
|
const digit = parseInt(e.key, 10);
|
||||||
if (digit >= 1 && digit <= 9) {
|
if (digit >= 1 && digit <= 9) {
|
||||||
const index = digit - 1;
|
const index = digit - 1;
|
||||||
if (index < streams.length) {
|
if (index < items.length) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onNavigate(streams[index].id);
|
onOpen(items[index].id);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter: navigate to selected
|
// Enter: open selected
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
setSelectedIndex((idx) => {
|
setSelectedIndex((idx) => {
|
||||||
if (idx !== null && idx < streams.length) {
|
if (idx !== null && idx < items.length) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onNavigate(streams[idx].id);
|
onOpen(items[idx].id);
|
||||||
}
|
}
|
||||||
return idx;
|
return idx;
|
||||||
});
|
});
|
||||||
@@ -86,7 +90,7 @@ export function useStreamKeyboardNav({
|
|||||||
setSelectedIndex((prev) => {
|
setSelectedIndex((prev) => {
|
||||||
if (prev === null) return 0;
|
if (prev === null) return 0;
|
||||||
const next = prev + delta;
|
const next = prev + delta;
|
||||||
return Math.max(0, Math.min(next, streams.length - 1));
|
return Math.max(0, Math.min(next, items.length - 1));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -95,7 +99,7 @@ export function useStreamKeyboardNav({
|
|||||||
// components (ToggleGroup, etc.) consume them for their own navigation.
|
// components (ToggleGroup, etc.) consume them for their own navigation.
|
||||||
window.addEventListener('keydown', handleKeyDown, true);
|
window.addEventListener('keydown', handleKeyDown, true);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
}, [enabled, streams, onNavigate, recordingMode, setRecordingMode]);
|
}, [enabled, items, onOpen, recordingMode, setRecordingMode]);
|
||||||
|
|
||||||
return { selectedIndex };
|
return { selectedIndex };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const DEFAULT_DEBOUNCE_MS = 600;
|
||||||
|
|
||||||
|
interface UseLiveDraftFieldOptions {
|
||||||
|
remoteValue: string;
|
||||||
|
commit: (value: string) => void | Promise<void>;
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveDraftField {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for a multiplayer always-editable text field: local draft while
|
||||||
|
* typing, debounced write-through, immediate flush on blur. Incoming remote
|
||||||
|
* values are applied only while the field is unfocused, so collaborators'
|
||||||
|
* snapshot updates never clobber in-progress typing (blur flushes pending
|
||||||
|
* writes synchronously, so unfocused implies no pending draft). Concurrent
|
||||||
|
* edits to the same field are last-write-wins.
|
||||||
|
*/
|
||||||
|
export function useLiveDraftField({
|
||||||
|
remoteValue,
|
||||||
|
commit,
|
||||||
|
debounceMs = DEFAULT_DEBOUNCE_MS,
|
||||||
|
}: UseLiveDraftFieldOptions): LiveDraftField {
|
||||||
|
const [value, setValue] = useState(remoteValue);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
|
// Sync from remote during render (derived-state pattern) unless the user
|
||||||
|
// is editing the field.
|
||||||
|
const [prevRemote, setPrevRemote] = useState(remoteValue);
|
||||||
|
if (remoteValue !== prevRemote) {
|
||||||
|
setPrevRemote(remoteValue);
|
||||||
|
if (!focused) {
|
||||||
|
setValue(remoteValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written only in handlers; read by flush() so its identity stays stable.
|
||||||
|
const valueRef = useRef(remoteValue);
|
||||||
|
const pendingRef = useRef(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
const commitRef = useRef(commit);
|
||||||
|
useEffect(() => {
|
||||||
|
commitRef.current = commit;
|
||||||
|
}, [commit]);
|
||||||
|
|
||||||
|
const flush = useCallback(() => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = undefined;
|
||||||
|
if (!pendingRef.current) return;
|
||||||
|
pendingRef.current = false;
|
||||||
|
void commitRef.current(valueRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Flush any pending write when the field unmounts (e.g. playback advances).
|
||||||
|
useEffect(() => flush, [flush]);
|
||||||
|
|
||||||
|
const onChange = useCallback(
|
||||||
|
(next: string) => {
|
||||||
|
valueRef.current = next;
|
||||||
|
setValue(next);
|
||||||
|
pendingRef.current = true;
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(flush, debounceMs);
|
||||||
|
},
|
||||||
|
[debounceMs, flush],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFocus = useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onBlur = useCallback(() => {
|
||||||
|
setFocused(false);
|
||||||
|
flush();
|
||||||
|
}, [flush]);
|
||||||
|
|
||||||
|
return { value, onChange, onFocus, onBlur };
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type RefObject,
|
||||||
|
} from 'react';
|
||||||
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
|
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
|
||||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +21,10 @@ interface UsePlaybackKeysOptions {
|
|||||||
|
|
||||||
interface UsePlaybackKeysResult {
|
interface UsePlaybackKeysResult {
|
||||||
fastPlayback: boolean;
|
fastPlayback: boolean;
|
||||||
|
/** True while playback is paused by the user's space toggle/hold. */
|
||||||
|
spacePaused: boolean;
|
||||||
|
/** Clear the user's space pause (e.g. clicking the on-screen play button). */
|
||||||
|
resume: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +41,11 @@ export function usePlaybackKeys({
|
|||||||
|
|
||||||
useSuspendPlayback(spaceHeld, 'hold-space');
|
useSuspendPlayback(spaceHeld, 'hold-space');
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
setSpaceHeld(false);
|
||||||
|
spaceStartRef.current = 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isExternallyPaused = () =>
|
const isExternallyPaused = () =>
|
||||||
selectIsPaused(usePlaybackPauseStore.getState()) && !spaceHeld;
|
selectIsPaused(usePlaybackPauseStore.getState()) && !spaceHeld;
|
||||||
@@ -92,5 +107,5 @@ export function usePlaybackKeys({
|
|||||||
};
|
};
|
||||||
}, [mediaRef, spaceHeld]);
|
}, [mediaRef, spaceHeld]);
|
||||||
|
|
||||||
return { fastPlayback };
|
return { fastPlayback, spacePaused: spaceHeld, resume };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface HumanPresence {
|
|||||||
humanId: string;
|
humanId: string;
|
||||||
email: string;
|
email: string;
|
||||||
emailPrefix: string;
|
emailPrefix: string;
|
||||||
|
avatarObjectId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +43,7 @@ export function usePresencePositions(
|
|||||||
humanId: human.id,
|
humanId: human.id,
|
||||||
email: human.email,
|
email: human.email,
|
||||||
emailPrefix: human.email_prefix,
|
emailPrefix: human.email_prefix,
|
||||||
|
avatarObjectId: human.avatar_object_id ?? null,
|
||||||
};
|
};
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.push(presence);
|
existing.push(presence);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface UseStreamNavigationKeysOptions {
|
|||||||
childrenLength: number;
|
childrenLength: number;
|
||||||
mediaRef: RefObject<MediaParticleHandle | null>;
|
mediaRef: RefObject<MediaParticleHandle | null>;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
|
onToggleViewMode: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,7 @@ export function useStreamNavigationKeys({
|
|||||||
childrenLength,
|
childrenLength,
|
||||||
mediaRef,
|
mediaRef,
|
||||||
onExit,
|
onExit,
|
||||||
|
onToggleViewMode,
|
||||||
}: UseStreamNavigationKeysOptions) {
|
}: UseStreamNavigationKeysOptions) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -54,10 +56,25 @@ export function useStreamNavigationKeys({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onExit();
|
onExit();
|
||||||
break;
|
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);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [next, prev, currentIndex, childrenLength, mediaRef, onExit]);
|
}, [
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
currentIndex,
|
||||||
|
childrenLength,
|
||||||
|
mediaRef,
|
||||||
|
onExit,
|
||||||
|
onToggleViewMode,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { where, type QueryFieldFilterConstraint } from 'firebase/firestore';
|
|
||||||
import { useLiveParticleChildren } from '@/hooks/use-particle';
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
|
||||||
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
|
||||||
import type { Particle, StreamProperties } from '@/api/types';
|
|
||||||
|
|
||||||
export type StreamParticle = Particle & {
|
|
||||||
type: 'stream';
|
|
||||||
properties: StreamProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
const INITIAL_PAGE_SIZE = 12;
|
|
||||||
const PAGE_INCREMENT = 12;
|
|
||||||
|
|
||||||
// Stable where-constraint references so the Firestore subscription only
|
|
||||||
// re-attaches when the tab actually changes, not on every render.
|
|
||||||
const OPEN_STATUS_FILTER = where('status', '==', 'open');
|
|
||||||
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
|
|
||||||
|
|
||||||
function useVisibilityScopes(userId?: string, networkId?: string) {
|
|
||||||
return useMemo(() => {
|
|
||||||
const scopes: string[] = [];
|
|
||||||
if (userId) scopes.push(`human:${userId}`);
|
|
||||||
if (networkId) scopes.push(`network:${networkId}`);
|
|
||||||
return scopes;
|
|
||||||
}, [userId, networkId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseStreamParticlesOptions {
|
|
||||||
// Which streams to subscribe to
|
|
||||||
status: 'open' | 'closed';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseStreamParticlesResult {
|
|
||||||
streams: StreamParticle[];
|
|
||||||
isLoading: boolean;
|
|
||||||
networkId: string;
|
|
||||||
/** True when more streams may exist beyond the current window. */
|
|
||||||
canLoadMore: boolean;
|
|
||||||
/** Extend the pagination window. */
|
|
||||||
loadMore: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStreamParticles(
|
|
||||||
path: ParticlePath,
|
|
||||||
{ status }: UseStreamParticlesOptions,
|
|
||||||
): UseStreamParticlesResult {
|
|
||||||
const { networkId } = parseParticlePath(path);
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
|
||||||
|
|
||||||
const [limit, setLimit] = useState(INITIAL_PAGE_SIZE);
|
|
||||||
|
|
||||||
const whereFilter: QueryFieldFilterConstraint =
|
|
||||||
status === 'open' ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
|
|
||||||
|
|
||||||
const { children, isLoading } = useLiveParticleChildren(path, {
|
|
||||||
orderByField: 'last_child_created_at',
|
|
||||||
orderDirection: 'desc',
|
|
||||||
visibilityScopes,
|
|
||||||
whereFilter,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const streams = useMemo(
|
|
||||||
() => children.filter((c): c is StreamParticle => c.type === 'stream'),
|
|
||||||
[children],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Heuristic: if we got back as many items as we asked for, assume there
|
|
||||||
// might be more. Clicking load-more when there are no more is a no-op.
|
|
||||||
const canLoadMore = streams.length >= limit;
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
|
||||||
setLimit((prev) => prev + PAGE_INCREMENT);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { streams, isLoading, networkId, canLoadMore, loadMore };
|
|
||||||
}
|
|
||||||
@@ -98,12 +98,26 @@ interface UseStreamPlaybackResult {
|
|||||||
goToParticle: (particleId: string) => void;
|
goToParticle: (particleId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UseStreamPlaybackOptions {
|
||||||
|
/**
|
||||||
|
* When false, newly arriving particles don't pull playback forward after
|
||||||
|
* it has ended (list mode browses; selection must stay put). Default true.
|
||||||
|
*/
|
||||||
|
autoAdvanceOnNew?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function useStreamPlayback(
|
export function useStreamPlayback(
|
||||||
streamParticle: Particle & { type: 'stream' },
|
streamParticle: Particle & { type: 'stream' },
|
||||||
path: ParticlePath,
|
path: ParticlePath,
|
||||||
|
{ autoAdvanceOnNew = true }: UseStreamPlaybackOptions = {},
|
||||||
): UseStreamPlaybackResult {
|
): UseStreamPlaybackResult {
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
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
|
// Track the stream ID we've initialized for, to reset when navigating between streams
|
||||||
const initializedForRef = useRef<string | null>(null);
|
const initializedForRef = useRef<string | null>(null);
|
||||||
// Latest currentIndex for onParticleRemoved, which is passed into
|
// Latest currentIndex for onParticleRemoved, which is passed into
|
||||||
@@ -115,6 +129,7 @@ export function useStreamPlayback(
|
|||||||
|
|
||||||
// --- Firestore change callbacks ---
|
// --- Firestore change callbacks ---
|
||||||
const onParticleAdded = useCallback((particle: Particle) => {
|
const onParticleAdded = useCallback((particle: Particle) => {
|
||||||
|
if (!autoAdvanceOnNewRef.current) return;
|
||||||
dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id });
|
dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export type StreamViewMode = 'player' | 'list';
|
||||||
|
|
||||||
|
export function useStreamViewMode(): {
|
||||||
|
mode: StreamViewMode;
|
||||||
|
toggle: () => void;
|
||||||
|
} {
|
||||||
|
const [mode, setMode] = useState<StreamViewMode>('player');
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setMode((prev) => (prev === 'player' ? 'list' : 'player'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { mode, toggle };
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Center-crop an image source to a square and downscale it to a JPEG suitable
|
||||||
|
* for an avatar. Avatars never render larger than ~80px, so 512px is generous
|
||||||
|
* headroom while keeping the upload to a few tens of KB regardless of input.
|
||||||
|
*
|
||||||
|
* Pass `mirror` when capturing from a (mirrored) webcam preview so the saved
|
||||||
|
* image matches what the user saw.
|
||||||
|
*/
|
||||||
|
export async function toAvatarBlob(
|
||||||
|
source: ImageBitmap,
|
||||||
|
{ size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
|
||||||
|
): Promise<Blob> {
|
||||||
|
if (!Number.isInteger(size) || size <= 0) {
|
||||||
|
throw new Error('Avatar size must be a positive integer');
|
||||||
|
}
|
||||||
|
const side = Math.min(source.width, source.height);
|
||||||
|
if (side === 0) {
|
||||||
|
// A not-yet-decoded <video> or a corrupt image yields a zero-size source;
|
||||||
|
// cropping it would silently produce a blank avatar, so fail instead.
|
||||||
|
throw new Error('Image source has zero dimensions');
|
||||||
|
}
|
||||||
|
const sx = (source.width - side) / 2;
|
||||||
|
const sy = (source.height - side) / 2;
|
||||||
|
|
||||||
|
const canvas = new OffscreenCanvas(size, size);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Failed to acquire 2D canvas context');
|
||||||
|
|
||||||
|
if (mirror) {
|
||||||
|
ctx.translate(size, 0);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
}
|
||||||
|
ctx.drawImage(source, sx, sy, side, side, 0, 0, size, size);
|
||||||
|
|
||||||
|
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.82 });
|
||||||
|
}
|
||||||
@@ -4,6 +4,15 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
|||||||
/** Maximum number of file attachments per particle. */
|
/** Maximum number of file attachments per particle. */
|
||||||
export const MAX_ATTACHMENTS = 10;
|
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 SUPPORT_EMAIL = 'team@flowylabs.ai';
|
||||||
|
|
||||||
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 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,7 +23,11 @@ import {
|
|||||||
QueryFieldFilterConstraint,
|
QueryFieldFilterConstraint,
|
||||||
} from 'firebase/firestore';
|
} from 'firebase/firestore';
|
||||||
import { firestoreDb } from '@/firebase';
|
import { firestoreDb } from '@/firebase';
|
||||||
import { isContainerType, ParticleSchema } from '@/api/types';
|
import {
|
||||||
|
isContainerType,
|
||||||
|
ParticleSchema,
|
||||||
|
UnknownParticleSchema,
|
||||||
|
} from '@/api/types';
|
||||||
import type {
|
import type {
|
||||||
Particle,
|
Particle,
|
||||||
ParticleType,
|
ParticleType,
|
||||||
@@ -33,6 +37,22 @@ import type {
|
|||||||
|
|
||||||
// --- Converter ---
|
// --- 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> = {
|
const particleConverter: FirestoreDataConverter<Particle> = {
|
||||||
toFirestore(particle: Particle): DocumentData {
|
toFirestore(particle: Particle): DocumentData {
|
||||||
const { id: _id, created_at, updated_at, ...rest } = particle;
|
const { id: _id, created_at, updated_at, ...rest } = particle;
|
||||||
@@ -60,6 +80,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
id: snap.id,
|
id: snap.id,
|
||||||
type: raw.type,
|
type: raw.type,
|
||||||
properties: raw.properties,
|
properties: raw.properties,
|
||||||
|
status: raw.status ?? undefined,
|
||||||
created_at: (raw.created_at as Timestamp).toDate(),
|
created_at: (raw.created_at as Timestamp).toDate(),
|
||||||
created_by_human_id: raw.created_by_human_id,
|
created_by_human_id: raw.created_by_human_id,
|
||||||
updated_at: raw.updated_at
|
updated_at: raw.updated_at
|
||||||
@@ -79,7 +100,6 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
: undefined,
|
: undefined,
|
||||||
huddle_active_participants:
|
huddle_active_participants:
|
||||||
raw.huddle_active_participants ?? undefined,
|
raw.huddle_active_participants ?? undefined,
|
||||||
status: raw.status ?? undefined,
|
|
||||||
});
|
});
|
||||||
case 'folder':
|
case 'folder':
|
||||||
return ParticleSchema.parse({
|
return ParticleSchema.parse({
|
||||||
@@ -92,21 +112,16 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
? (raw.updated_at as Timestamp).toDate()
|
? (raw.updated_at as Timestamp).toDate()
|
||||||
: undefined,
|
: undefined,
|
||||||
visible_to: raw.visible_to,
|
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 'media':
|
||||||
case 'file':
|
case 'file':
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'quest':
|
case 'task':
|
||||||
case 'paper': {
|
case 'paper': {
|
||||||
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
const properties = coerceLeafPropertyDates(type, raw.properties);
|
||||||
// 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({
|
return ParticleSchema.parse({
|
||||||
id: snap.id,
|
id: snap.id,
|
||||||
type: raw.type,
|
type: raw.type,
|
||||||
@@ -124,11 +139,33 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown particle type: ${type}`);
|
// Forward compatibility: keep unrecognized types visible as
|
||||||
|
// placeholders instead of breaking the snapshot they arrive in.
|
||||||
|
return UnknownParticleSchema.parse({
|
||||||
|
id: snap.id,
|
||||||
|
type: 'unknown',
|
||||||
|
raw_type: raw.type,
|
||||||
|
created_at: (raw.created_at as Timestamp).toDate(),
|
||||||
|
created_by_human_id: raw.created_by_human_id,
|
||||||
|
updated_at: raw.updated_at
|
||||||
|
? (raw.updated_at as Timestamp).toDate()
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Corrupt docs (malformed base fields, schema parse failures) shouldn't take
|
||||||
|
// down a whole subscription — skip just the bad doc and keep the rest.
|
||||||
|
function safeData(snap: QueryDocumentSnapshot<Particle>): Particle | null {
|
||||||
|
try {
|
||||||
|
return snap.data();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Skipping unparseable particle at ${snap.ref.path}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Typed reference helpers ---
|
// --- Typed reference helpers ---
|
||||||
|
|
||||||
function typedDoc(path: string) {
|
function typedDoc(path: string) {
|
||||||
@@ -149,7 +186,7 @@ export function subscribeToParticle(
|
|||||||
return onSnapshot(
|
return onSnapshot(
|
||||||
typedDoc(docPath),
|
typedDoc(docPath),
|
||||||
(snap) => {
|
(snap) => {
|
||||||
onData(snap.exists() ? snap.data() : null);
|
onData(snap.exists() ? safeData(snap) : null);
|
||||||
},
|
},
|
||||||
onError,
|
onError,
|
||||||
);
|
);
|
||||||
@@ -161,7 +198,7 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return doc.data();
|
return safeData(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetParticleChildrenOptions {
|
export interface GetParticleChildrenOptions {
|
||||||
@@ -184,7 +221,7 @@ export async function getParticleChildren(
|
|||||||
orderBy(orderByField, orderDirection),
|
orderBy(orderByField, orderDirection),
|
||||||
);
|
);
|
||||||
const snap = await getDocs(q);
|
const snap = await getDocs(q);
|
||||||
return snap.docs.map((d) => d.data());
|
return snap.docs.flatMap((d) => safeData(d) ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscribeToParticleChildrenOptions {
|
export interface SubscribeToParticleChildrenOptions {
|
||||||
@@ -230,14 +267,16 @@ export function subscribeToParticleChildren(
|
|||||||
return onSnapshot(
|
return onSnapshot(
|
||||||
q,
|
q,
|
||||||
(snap) => {
|
(snap) => {
|
||||||
const updatedChildren = snap.docs.map((d) => d.data());
|
const updatedChildren = snap.docs.flatMap((d) => safeData(d) ?? []);
|
||||||
onData(updatedChildren);
|
onData(updatedChildren);
|
||||||
|
|
||||||
if (onAdded || onRemoved) {
|
if (onAdded || onRemoved) {
|
||||||
for (const change of snap.docChanges()) {
|
for (const change of snap.docChanges()) {
|
||||||
if (change.type === 'added' && onAdded) onAdded(change.doc.data());
|
const child = safeData(change.doc);
|
||||||
|
if (!child) continue;
|
||||||
|
if (change.type === 'added' && onAdded) onAdded(child);
|
||||||
if (change.type === 'removed' && onRemoved)
|
if (change.type === 'removed' && onRemoved)
|
||||||
onRemoved(change.doc.data(), updatedChildren);
|
onRemoved(child, updatedChildren);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -258,12 +297,31 @@ export function subscribeToLatestChild(
|
|||||||
return onSnapshot(
|
return onSnapshot(
|
||||||
q,
|
q,
|
||||||
(snap) => {
|
(snap) => {
|
||||||
onData(snap.empty ? null : snap.docs[0].data());
|
onData(snap.empty ? null : safeData(snap.docs[0]));
|
||||||
},
|
},
|
||||||
onError,
|
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.
|
// This creates a new particle document with the given properties and returns its ID.
|
||||||
export async function createParticle<T extends ParticleType>(
|
export async function createParticle<T extends ParticleType>(
|
||||||
collectionPath: string,
|
collectionPath: string,
|
||||||
@@ -279,15 +337,21 @@ export async function createParticle<T extends ParticleType>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date();
|
||||||
const particle: Particle = ParticleSchema.parse({
|
const particle: Particle = ParticleSchema.parse({
|
||||||
id: '', // ignored by toFirestore, but needed to satisfy the type
|
id: '', // ignored by toFirestore, but needed to satisfy the type
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
created_at: new Date(),
|
created_at: createdAt,
|
||||||
created_by_human_id: createdByHumanId,
|
created_by_human_id: createdByHumanId,
|
||||||
...(visibleTo ? { visible_to: visibleTo } : {}),
|
...(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);
|
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||||
|
bumpParentLastChildCreatedAt(collectionPath, createdAt);
|
||||||
return ref.id;
|
return ref.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,19 +365,32 @@ export async function createStreamParticle(
|
|||||||
throw new Error('visibleTo is required for streams and cannot be empty');
|
throw new Error('visibleTo is required for streams and cannot be empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date();
|
||||||
const particle: Particle = ParticleSchema.parse({
|
const particle: Particle = ParticleSchema.parse({
|
||||||
id: '',
|
id: '',
|
||||||
type: 'stream',
|
type: 'stream',
|
||||||
properties,
|
properties,
|
||||||
created_at: new Date(),
|
status: 'open',
|
||||||
|
created_at: createdAt,
|
||||||
created_by_human_id: createdByHumanId,
|
created_by_human_id: createdByHumanId,
|
||||||
visible_to: visibleTo,
|
visible_to: visibleTo,
|
||||||
status: 'open',
|
last_child_created_at: createdAt,
|
||||||
});
|
});
|
||||||
const ref = await addDoc(typedCollection(collectionPath), particle);
|
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||||
|
bumpParentLastChildCreatedAt(collectionPath, createdAt);
|
||||||
return ref.id;
|
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
|
// This allows updating properties without overwriting the entire properties object
|
||||||
export async function updateParticleProperties<T extends ParticleType>(
|
export async function updateParticleProperties<T extends ParticleType>(
|
||||||
docPath: string,
|
docPath: string,
|
||||||
@@ -382,14 +459,6 @@ export async function updateParticle(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateStreamStatus(
|
|
||||||
docPath: string,
|
|
||||||
status: 'open' | 'closed',
|
|
||||||
): Promise<void> {
|
|
||||||
const particleRef = typedDoc(docPath);
|
|
||||||
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft-delete (tombstone) a non-container particle. The Firestore doc stays
|
* Soft-delete (tombstone) a non-container particle. The Firestore doc stays
|
||||||
* in place so concurrent viewers see the deletion inline rather than being
|
* in place so concurrent viewers see the deletion inline rather than being
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface HumanDisplay {
|
|||||||
email: string;
|
email: string;
|
||||||
/** Initials for avatar fallback. */
|
/** Initials for avatar fallback. */
|
||||||
initials: string;
|
initials: string;
|
||||||
|
/** Avatar object id, when the human has a profile picture set. */
|
||||||
|
avatarObjectId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +34,7 @@ export function resolveHumanDisplay(
|
|||||||
displayName: REMOVED_MEMBER_LABEL,
|
displayName: REMOVED_MEMBER_LABEL,
|
||||||
email: REMOVED_MEMBER_LABEL,
|
email: REMOVED_MEMBER_LABEL,
|
||||||
initials: REMOVED_MEMBER_INITIALS,
|
initials: REMOVED_MEMBER_INITIALS,
|
||||||
|
avatarObjectId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -39,5 +42,6 @@ export function resolveHumanDisplay(
|
|||||||
displayName: human.email_prefix,
|
displayName: human.email_prefix,
|
||||||
email: human.email,
|
email: human.email,
|
||||||
initials: getInitials(human.email),
|
initials: getInitials(human.email),
|
||||||
|
avatarObjectId: human.avatar_object_id ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** Font scale for short messages shown as large centered text. Shared by the
|
||||||
|
* composer and the posted view so editing matches the final render. */
|
||||||
|
export function getImmersiveTextStyle(length: number) {
|
||||||
|
if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' };
|
||||||
|
if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' };
|
||||||
|
return { size: 'text-2xl', weight: 'font-normal' };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user