mobile v0.1 with deployment for ios #191
@@ -17,6 +17,6 @@ jobs:
|
||||
deploy:
|
||||
uses: ./.github/workflows/_deploy.yml
|
||||
with:
|
||||
module: worker
|
||||
module: particleprocessor
|
||||
environment: ${{ inputs.environment }}
|
||||
secrets: inherit
|
||||
@@ -14,6 +14,8 @@ RUN ls
|
||||
|
||||
FROM alpine:latest AS second-stage
|
||||
|
||||
RUN apk add --no-cache ffmpeg ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=first-stage /app/cmd/particleprocessorworker .
|
||||
RUN echo "copied over binary to production stage"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -107,6 +109,7 @@ func main() {
|
||||
|
||||
updateParentLastChildCreatedAt(ctx, change.Doc)
|
||||
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
||||
transcodeMediaParticle(ctx, depotSvc, change.Doc)
|
||||
recordFreemiumUsage(ctx, billingSvc, change.Doc)
|
||||
|
||||
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
||||
@@ -162,6 +165,152 @@ func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speech
|
||||
slog.Info("transcribed media particle", "particleID", doc.Ref.ID)
|
||||
}
|
||||
|
||||
// transcodeMediaParticle produces an iOS-playable MP4/m4a derivative for media
|
||||
// particles whose original mime type AVPlayer can't decode (notably the WebM
|
||||
// the desktop recorder emits today). Skips when the source is already in an
|
||||
// iOS-playable family or when a transcoded variant has already been written.
|
||||
//
|
||||
// ffmpeg reads directly from the GCS signed URL and writes to a local temp
|
||||
// file — `+faststart` requires seekable output, so a stdout pipe wouldn't work.
|
||||
// The temp file is then streamed to GCS via depotSvc.CreateFromReader (no
|
||||
// presigned-PUT round-trip — the worker has direct SDK access).
|
||||
func transcodeMediaParticle(ctx context.Context, depotSvc depot.Service, doc *firestore.DocumentSnapshot) {
|
||||
var mediaParticle particle.FirestoreMediaParticle
|
||||
if err := doc.DataTo(&mediaParticle); err != nil {
|
||||
slog.Error("transcode: unable to marshal particle data", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
particleType, err := particle.ParseParticleType(mediaParticle.Type)
|
||||
if err != nil {
|
||||
slog.Error("transcode: invalid particle type", "error", err)
|
||||
return
|
||||
}
|
||||
if particleType != particle.TypeMedia {
|
||||
slog.Info("transcode: particle is not of type media")
|
||||
return
|
||||
}
|
||||
|
||||
// Already transcoded — re-delivery within the 5-min Firestore window.
|
||||
if mediaParticle.Properties.TranscodedObjectId != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Source is already iOS-playable; nothing to do.
|
||||
if isIOSPlayableMime(mediaParticle.Properties.MimeType) {
|
||||
slog.Info("transcode: skipping because already playable on ios")
|
||||
return
|
||||
}
|
||||
|
||||
sourceURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
|
||||
if err != nil {
|
||||
slog.Error("transcode: failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
|
||||
return
|
||||
}
|
||||
|
||||
isAudio := strings.HasPrefix(mediaParticle.Properties.MimeType, "audio/")
|
||||
var outputExt, outputMime string
|
||||
if isAudio {
|
||||
outputExt = ".m4a"
|
||||
outputMime = "audio/mp4"
|
||||
} else {
|
||||
outputExt = ".mp4"
|
||||
outputMime = "video/mp4"
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "transcode-*"+outputExt)
|
||||
if err != nil {
|
||||
slog.Error("transcode: failed to create temp file", "error", err)
|
||||
return
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
tmp.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
transcodeCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var args []string
|
||||
if isAudio {
|
||||
args = []string{
|
||||
"-y", "-i", sourceURL,
|
||||
"-vn",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-movflags", "+faststart",
|
||||
tmpPath,
|
||||
}
|
||||
} else {
|
||||
// Cap encoder parallelism and lookahead to keep memory bounded — screen
|
||||
// recordings come in at native display resolution (often 1440p–4K) and
|
||||
// libx264's per-thread lookahead/reference buffers blow past the worker's
|
||||
// memory limit otherwise. Output is also downscaled to 1080p max, which
|
||||
// mobile playback won't notice; the original WebM stays in GCS untouched.
|
||||
args = []string{
|
||||
"-y", "-i", sourceURL,
|
||||
"-vf", "scale='min(1920,iw)':-2:flags=lanczos",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
||||
"-pix_fmt", "yuv420p", "-profile:v", "baseline", "-level", "3.1",
|
||||
"-x264-params", "rc-lookahead=20:ref=2",
|
||||
"-threads", "2", "-filter_threads", "2",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-movflags", "+faststart",
|
||||
tmpPath,
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(transcodeCtx, "ffmpeg", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("transcode: ffmpeg failed", "error", err, "stderr", stderr.String(), "particleID", doc.Ref.ID)
|
||||
return
|
||||
}
|
||||
|
||||
networkID, err := networkIDFromParticlePath(doc.Ref.Path)
|
||||
if err != nil {
|
||||
slog.Error("transcode: failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(tmpPath)
|
||||
if err != nil {
|
||||
slog.Error("transcode: failed to open transcoded file", "error", err, "path", tmpPath)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
newObj, err := depotSvc.CreateFromReader(ctx, depot.CreateFromReaderInput{
|
||||
Prefix: networkID,
|
||||
Name: "transcoded" + outputExt,
|
||||
ContentType: outputMime,
|
||||
}, f)
|
||||
if err != nil {
|
||||
slog.Error("transcode: failed to upload transcoded object", "error", err, "particleID", doc.Ref.ID)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = doc.Ref.Set(ctx, map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"transcoded_object_id": newObj.ID,
|
||||
"transcoded_mime_type": outputMime,
|
||||
},
|
||||
}, firestore.MergeAll)
|
||||
if err != nil {
|
||||
slog.Error("transcode: failed to update particle in firestore", "error", err, "particleID", doc.Ref.ID)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("transcoded media particle", "particleID", doc.Ref.ID, "transcoded_object_id", newObj.ID, "mime", outputMime)
|
||||
}
|
||||
|
||||
func isIOSPlayableMime(mime string) bool {
|
||||
switch mime {
|
||||
case "video/mp4", "video/quicktime", "audio/mp4", "audio/aac", "audio/x-m4a", "audio/mpeg":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
|
||||
words := make([]particle.FirestoreTranscriptWord, len(result.Words))
|
||||
for i, w := range result.Words {
|
||||
|
||||
@@ -177,6 +177,7 @@ require (
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
@@ -184,3 +185,5 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.110.1 // indirect
|
||||
)
|
||||
|
||||
tool go.uber.org/mock/mockgen
|
||||
|
||||
@@ -477,6 +477,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
stripesub "github.com/stripe/stripe-go/v85/subscription"
|
||||
)
|
||||
|
||||
//go:generate mockgen -source ./service.go -destination ./mocks/service.go
|
||||
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
||||
|
||||
type Service interface {
|
||||
GetStatus(ctx context.Context, networkID string) (*Status, error)
|
||||
|
||||
@@ -29,6 +29,15 @@ type PrepareUploadResult struct {
|
||||
UploadHeaders map[string]string
|
||||
}
|
||||
|
||||
// CreateFromReaderInput is for server-side direct uploads (no presigned URL).
|
||||
// Used by background workers that already have the bytes on hand and don't
|
||||
// need a client round-trip.
|
||||
type CreateFromReaderInput struct {
|
||||
Prefix string // Optional prefix for organizing objects (e.g., network_id)
|
||||
Name string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Config holds configuration for the depot service
|
||||
type Config struct {
|
||||
GoogleServiceAccountEmail string
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
@@ -20,6 +21,7 @@ const (
|
||||
type Service interface {
|
||||
PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error)
|
||||
ConfirmUpload(ctx context.Context, objectID string) (*Object, error)
|
||||
CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error)
|
||||
GetByID(ctx context.Context, objectID string) (*Object, error)
|
||||
GetDownloadURL(ctx context.Context, objectID string) (string, error)
|
||||
Delete(ctx context.Context, objectID string) error
|
||||
@@ -155,6 +157,56 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
||||
return s.repo.getByID(ctx, objectID)
|
||||
}
|
||||
|
||||
// CreateFromReader streams bytes directly to GCS using the storage client and
|
||||
// records the depot_objects row in one shot. Unlike PrepareUpload, there is no
|
||||
// signed URL or client round-trip — the caller already has the bytes. Intended
|
||||
// for worker-side flows (e.g. transcoded media variants).
|
||||
func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) {
|
||||
if input.Name == "" {
|
||||
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
|
||||
}
|
||||
if input.ContentType == "" {
|
||||
return nil, errors.Join(ErrInvalidInput, errors.New("content_type is required"))
|
||||
}
|
||||
|
||||
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
|
||||
|
||||
w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
|
||||
w.ContentType = input.ContentType
|
||||
if _, err := io.Copy(w, body); err != nil {
|
||||
// Close to release resources, then surface the original copy error.
|
||||
if cerr := w.Close(); cerr != nil {
|
||||
slog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey)
|
||||
}
|
||||
slog.Error("failed to stream object to GCS", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
slog.Error("failed to close GCS writer", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj := &Object{
|
||||
Name: input.Name,
|
||||
ContentType: input.ContentType,
|
||||
ContentLength: w.Attrs().Size,
|
||||
BucketName: s.bucketName,
|
||||
ObjectKey: objectKey,
|
||||
ContainsContent: true,
|
||||
}
|
||||
|
||||
created, err := s.repo.create(ctx, obj)
|
||||
if err != nil {
|
||||
// Best-effort: clean up the GCS object since we can't track it in the DB.
|
||||
if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil {
|
||||
slog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetByID(ctx context.Context, objectID string) (*Object, error) {
|
||||
obj, err := s.repo.getByID(ctx, objectID)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"cloud.google.com/go/firestore"
|
||||
)
|
||||
|
||||
//go:generate mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
|
||||
//go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
|
||||
|
||||
// MembershipPublisher publishes network membership changes to the live store
|
||||
// (Firestore) that clients subscribe to. Postgres remains the source of truth;
|
||||
|
||||
@@ -35,11 +35,13 @@ type FirestoreTranscript struct {
|
||||
}
|
||||
|
||||
type FirestoreMediaParticleProperties struct {
|
||||
ObjectId string `firestore:"object_id"`
|
||||
MimeType string `firestore:"mime_type"`
|
||||
DurationMs int `firestore:"duration_ms"`
|
||||
SizeBytes int `firestore:"size_bytes"`
|
||||
Transcript *FirestoreTranscript `firestore:"transcript,omitempty"`
|
||||
ObjectId string `firestore:"object_id"`
|
||||
MimeType string `firestore:"mime_type"`
|
||||
DurationMs int `firestore:"duration_ms"`
|
||||
SizeBytes int `firestore:"size_bytes"`
|
||||
Transcript *FirestoreTranscript `firestore:"transcript,omitempty"`
|
||||
TranscodedObjectId string `firestore:"transcoded_object_id,omitempty"`
|
||||
TranscodedMimeType string `firestore:"transcoded_mime_type,omitempty"`
|
||||
}
|
||||
|
||||
type FirestoreStreamParticle struct {
|
||||
|
||||
@@ -2,7 +2,7 @@ package particle
|
||||
|
||||
import "context"
|
||||
|
||||
//go:generate mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
|
||||
//go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
|
||||
|
||||
type NetworkMembershipChecker interface {
|
||||
// IsMember returns true if the humanId is a member of the network.
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
// directive lives here rather than next to the source.
|
||||
package aero
|
||||
|
||||
//go:generate mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
|
||||
//go:generate go tool mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
|
||||
|
||||
@@ -21,11 +21,11 @@ spec:
|
||||
image: "particleprocessorworker"
|
||||
resources:
|
||||
requests:
|
||||
memory: "52Mi"
|
||||
cpu: 50m
|
||||
memory: "1Gi"
|
||||
cpu: 500m
|
||||
limits:
|
||||
memory: "52Mi"
|
||||
cpu: 50m
|
||||
memory: "1Gi"
|
||||
cpu: 500m
|
||||
env:
|
||||
- name: "GCP_PROJECT"
|
||||
value: "flowy-dev-440017"
|
||||
|
||||
@@ -18,11 +18,11 @@ spec:
|
||||
image: "particleprocessorworker"
|
||||
resources:
|
||||
requests:
|
||||
memory: "52Mi"
|
||||
cpu: 50m
|
||||
memory: "1Gi"
|
||||
cpu: 500m
|
||||
limits:
|
||||
memory: "52Mi"
|
||||
cpu: 50m
|
||||
memory: "1Gi"
|
||||
cpu: 500m
|
||||
env:
|
||||
- name: "GCP_PROJECT"
|
||||
value: "flowy-prod-440017"
|
||||
|
||||
@@ -59,7 +59,7 @@ profiles:
|
||||
apiVersion: skaffold/v4beta11
|
||||
kind: Config
|
||||
metadata:
|
||||
name: worker
|
||||
name: particleprocessor
|
||||
build:
|
||||
local: {}
|
||||
tagPolicy:
|
||||
|
||||
@@ -123,6 +123,11 @@ export const MediaPropertiesSchema = z.object({
|
||||
size_bytes: z.number(),
|
||||
transcript: TranscriptSchema.optional(),
|
||||
source: z.enum(["camera", "screen"]).optional(),
|
||||
// Set by the particle processor worker once an iOS-playable MP4/m4a variant
|
||||
// has been produced from a non-iOS-playable original (e.g. WebM from desktop).
|
||||
// When present, clients should prefer these over object_id/mime_type for playback.
|
||||
transcoded_object_id: z.string().optional(),
|
||||
transcoded_mime_type: z.string().optional(),
|
||||
});
|
||||
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
|
||||
|
||||
|
||||
@@ -33,12 +33,18 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
||||
onEnded,
|
||||
onProgress,
|
||||
}, ref) {
|
||||
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
|
||||
// Prefer the worker-produced iOS-playable variant when present so desktop
|
||||
// and mobile read the same canonical asset. Falls back to the original.
|
||||
const activeObjectId =
|
||||
particle.properties.transcoded_object_id ?? particle.properties.object_id;
|
||||
const activeMime =
|
||||
particle.properties.transcoded_mime_type ?? particle.properties.mime_type;
|
||||
const { data: url, error } = useDownloadUrl(activeObjectId);
|
||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const isAudio = particle.properties.mime_type?.startsWith("audio/");
|
||||
const isAudio = activeMime?.startsWith("audio/");
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek(deltaSec: number) {
|
||||
@@ -153,7 +159,7 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
||||
playsInline
|
||||
onEnded={onEnded}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
className={`h-full w-full ${particle.properties.source === "screen" ? "object-contain bg-black" : "object-cover"}`}
|
||||
className="h-full w-full object-contain bg-black"
|
||||
/>
|
||||
|
||||
{transcript && (
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
ios/
|
||||
android/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
expo-env.d.ts
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ExpoConfig } from "expo/config";
|
||||
|
||||
const APP_ENV = (process.env.EXPO_PUBLIC_APP_ENV ?? "dev") as "dev" | "prod";
|
||||
|
||||
const config: ExpoConfig = {
|
||||
name: "Flowy",
|
||||
slug: "flowy",
|
||||
version: "0.1.0",
|
||||
orientation: "portrait",
|
||||
icon: "./assets/icon.png",
|
||||
scheme: "flowy",
|
||||
userInterfaceStyle: "automatic",
|
||||
newArchEnabled: true,
|
||||
splash: {
|
||||
image: "./assets/icon.png",
|
||||
resizeMode: "contain",
|
||||
backgroundColor: "#000000",
|
||||
},
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: "ai.flowylabs.llink",
|
||||
infoPlist: {
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
ios: {
|
||||
deploymentTarget: "16.0",
|
||||
},
|
||||
},
|
||||
],
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
cameraPermission:
|
||||
"Flowy uses your camera to record video messages.",
|
||||
microphonePermission:
|
||||
"Flowy uses your microphone to record voice and video messages.",
|
||||
recordAudioAndroid: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-audio",
|
||||
{
|
||||
microphonePermission:
|
||||
"Flowy uses your microphone to record voice messages.",
|
||||
},
|
||||
],
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: false,
|
||||
},
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: "e902f7e5-e514-42bd-9591-75738e3494ed"
|
||||
},
|
||||
|
||||
appEnv: APP_ENV,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 667 B |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,10 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
plugins: ["react-native-worklets/plugin"],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 12.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_APP_ENV": "dev"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_APP_ENV": "dev"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"env": {
|
||||
"EXPO_PUBLIC_APP_ENV": "prod"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleTeamId": "RK9WWM4Q99"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,5 @@
|
||||
import "./global.css";
|
||||
import { registerRootComponent } from "expo";
|
||||
import App from "./src/App";
|
||||
|
||||
registerRootComponent(App);
|
||||
@@ -0,0 +1,6 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativeWind } = require("nativewind/metro");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: "./global.css" });
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "flowy-mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"ios": "expo run:ios --device",
|
||||
"publish:ios": "eas build --platform ios --auto-submit && echo 'Go to App Store Connect and submit the testflight build for app review. Visit for more information: https://docs.expo.dev/submit/introduction/'",
|
||||
"android": "expo run:android",
|
||||
"compile": "tsc --noEmit",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@react-navigation/native-stack": "^7.2.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"clsx": "^2.1.1",
|
||||
"expo": "~54.0.0",
|
||||
"expo-audio": "~1.0.13",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-file-system": "~19.0.16",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-video": "~3.0.10",
|
||||
"firebase": "^12.10.0",
|
||||
"lucide-react-native": "^0.575.0",
|
||||
"nativewind": "^4.1.23",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"sonner-native": "^0.21.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
} from "react-native-safe-area-context";
|
||||
import { Toaster } from "sonner-native";
|
||||
import { createQueryClient } from "@/lib/query-client";
|
||||
import { PusherProvider } from "@/lib/pusher-provider";
|
||||
import { RootNavigator } from "@/navigation/RootNavigator";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
export default function App() {
|
||||
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||
|
||||
useEffect(() => {
|
||||
void restoreSession();
|
||||
}, [restoreSession]);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PusherProvider>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<NavigationContainer>
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
<Toaster />
|
||||
<StatusBar style="auto" />
|
||||
</SafeAreaProvider>
|
||||
</PusherProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { appConfig } from "@/config/env";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
BillingStatusSchema,
|
||||
CheckoutSessionResponseSchema,
|
||||
DepotObjectSchema,
|
||||
FirebaseTokenResponseSchema,
|
||||
GetLivekitTokenResponseSchema,
|
||||
HumanSchema,
|
||||
ListInvitationsResponseSchema,
|
||||
ListNetworksResponseSchema,
|
||||
NetworkSchema,
|
||||
NetworkUsageSchema,
|
||||
PortalSessionResponseSchema,
|
||||
PrepareUploadResponseSchema,
|
||||
SignInResponseSchema,
|
||||
} from "./types";
|
||||
import type {
|
||||
AcceptInvitationRequest,
|
||||
AddMembersRequest,
|
||||
BillingCadence,
|
||||
CreateNetworkRequest,
|
||||
PrepareUploadRequest,
|
||||
RequestCodeRequest,
|
||||
RevokeInvitationRequest,
|
||||
SignInRequest,
|
||||
} from "./types";
|
||||
|
||||
interface ApiClientConfig {
|
||||
baseUrl: string;
|
||||
getToken: () => string | null;
|
||||
onUnauthorized: () => void;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private config: ApiClientConfig;
|
||||
|
||||
constructor(config: ApiClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const token = this.config.getToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
this.config.onUnauthorized();
|
||||
throw new ApiError(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "Unknown error");
|
||||
throw new ApiError(response.status, text);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
schema: z.ZodType<T>,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const response = await this.fetch(method, path, body);
|
||||
const json = await response.json();
|
||||
return schema.parse(json);
|
||||
}
|
||||
|
||||
private async requestVoid(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<void> {
|
||||
await this.fetch(method, path, body);
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
async requestCode(data: RequestCodeRequest): Promise<void> {
|
||||
await this.requestVoid("POST", "/auth/request-code", data);
|
||||
}
|
||||
|
||||
async signIn(data: SignInRequest) {
|
||||
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
|
||||
}
|
||||
|
||||
async me() {
|
||||
return this.request(HumanSchema, "GET", "/auth/me");
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
await this.requestVoid("POST", "/auth/sign-out");
|
||||
}
|
||||
|
||||
async getFirebaseToken() {
|
||||
return this.request(
|
||||
FirebaseTokenResponseSchema,
|
||||
"POST",
|
||||
"/auth/firebase-token",
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: security: require passing in the particle id once api deprecates this
|
||||
async getParticleDownloadUrl(objectId: string): Promise<string> {
|
||||
const response = await this.fetch(
|
||||
"GET",
|
||||
`/particles/${objectId}/download`,
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
async updateSettings(data: {
|
||||
email_notifications_enabled?: boolean;
|
||||
}): Promise<void> {
|
||||
await this.requestVoid("PATCH", "/humans/me/settings", data);
|
||||
}
|
||||
|
||||
// --- Depot ---
|
||||
|
||||
async prepareUpload(data: PrepareUploadRequest) {
|
||||
return this.request(
|
||||
PrepareUploadResponseSchema,
|
||||
"POST",
|
||||
"/depot/upload",
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
async confirmUpload(objectId: string) {
|
||||
return this.request(
|
||||
DepotObjectSchema,
|
||||
"POST",
|
||||
`/depot/objects/${objectId}/confirm`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Networks ---
|
||||
|
||||
async listNetworks() {
|
||||
return this.request(ListNetworksResponseSchema, "GET", "/networks");
|
||||
}
|
||||
|
||||
async createNetwork(data: CreateNetworkRequest) {
|
||||
return this.request(NetworkSchema, "POST", "/networks", data);
|
||||
}
|
||||
|
||||
async getNetwork(id: string) {
|
||||
return this.request(NetworkSchema, "GET", `/networks/${id}`);
|
||||
}
|
||||
|
||||
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
|
||||
await this.requestVoid("POST", `/networks/${networkId}/members`, data);
|
||||
}
|
||||
|
||||
async removeMember(networkId: string, humanId: string): Promise<void> {
|
||||
await this.requestVoid(
|
||||
"DELETE",
|
||||
`/networks/${networkId}/members/${humanId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Invitations ---
|
||||
|
||||
async listNetworkInvitations(networkId: string) {
|
||||
return this.request(
|
||||
ListInvitationsResponseSchema,
|
||||
"GET",
|
||||
`/networks/${networkId}/invitations`,
|
||||
);
|
||||
}
|
||||
|
||||
async listMyInvitations() {
|
||||
return this.request(ListInvitationsResponseSchema, "GET", "/invitations");
|
||||
}
|
||||
|
||||
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
|
||||
await this.requestVoid("POST", "/invitations/accept", data);
|
||||
}
|
||||
|
||||
async revokeInvitation(
|
||||
networkId: string,
|
||||
data: RevokeInvitationRequest,
|
||||
): Promise<void> {
|
||||
await this.requestVoid(
|
||||
"DELETE",
|
||||
`/networks/${networkId}/invitations`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// --- LiveKit ---
|
||||
|
||||
async getLivekitToken(networkId: string, streamId: string) {
|
||||
return this.request(
|
||||
GetLivekitTokenResponseSchema,
|
||||
"POST",
|
||||
"/livekit/token",
|
||||
{ network_id: networkId, stream_id: streamId },
|
||||
);
|
||||
}
|
||||
|
||||
// --- Billing (network admin only) ---
|
||||
|
||||
async getNetworkBilling(networkId: string) {
|
||||
return this.request(
|
||||
BillingStatusSchema,
|
||||
"GET",
|
||||
`/networks/${networkId}/billing`,
|
||||
);
|
||||
}
|
||||
|
||||
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
|
||||
return this.request(
|
||||
CheckoutSessionResponseSchema,
|
||||
"POST",
|
||||
`/networks/${networkId}/billing/checkout-session`,
|
||||
{ cadence },
|
||||
);
|
||||
}
|
||||
|
||||
async createPortalSession(networkId: string) {
|
||||
return this.request(
|
||||
PortalSessionResponseSchema,
|
||||
"POST",
|
||||
`/networks/${networkId}/billing/portal-session`,
|
||||
);
|
||||
}
|
||||
|
||||
async getNetworkUsage(networkId: string) {
|
||||
return this.request(
|
||||
NetworkUsageSchema,
|
||||
"GET",
|
||||
`/networks/${networkId}/usage`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient({
|
||||
baseUrl: appConfig.orionUrl,
|
||||
getToken: () => useSessionStore.getState().token,
|
||||
// SecureStore writes are async; we fire-and-forget so the throwing
|
||||
// request doesn't have to wait for persistence to finish.
|
||||
onUnauthorized: () => {
|
||||
void useSessionStore.getState().clearToken();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const HumanSchema = z.object({
|
||||
id: z.string(),
|
||||
created_at: z.coerce.date(),
|
||||
email: z.string().email(),
|
||||
email_prefix: z.string(),
|
||||
email_notifications_enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type Human = z.infer<typeof HumanSchema>;
|
||||
|
||||
export const NetworkSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
admin_human: HumanSchema,
|
||||
humans: z.array(HumanSchema),
|
||||
created_at: z.coerce.date(),
|
||||
});
|
||||
|
||||
export type Network = z.infer<typeof NetworkSchema>;
|
||||
|
||||
export const ListNetworksResponseSchema = z.array(NetworkSchema);
|
||||
export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
|
||||
|
||||
// --- Network request/response types ---
|
||||
|
||||
const CreateNetworkRequestSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
|
||||
|
||||
const AddMembersRequestSchema = z.object({
|
||||
email_addresses: z.array(z.string().email()),
|
||||
});
|
||||
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
|
||||
|
||||
// --- Invitation types ---
|
||||
|
||||
export const InvitationSchema = z.object({
|
||||
network_id: z.string(),
|
||||
network_name: z.string(),
|
||||
email: z.string(),
|
||||
created_at: z.coerce.date(),
|
||||
});
|
||||
export type Invitation = z.infer<typeof InvitationSchema>;
|
||||
|
||||
export const ListInvitationsResponseSchema = z.array(InvitationSchema);
|
||||
|
||||
export type AcceptInvitationRequest = { network_id: string };
|
||||
export type RevokeInvitationRequest = { email: string };
|
||||
|
||||
// --- Depot types ---
|
||||
|
||||
const PrepareUploadRequestSchema = z.object({
|
||||
network_id: z.string(),
|
||||
name: z.string(),
|
||||
content_type: z.string(),
|
||||
content_length: z.number(),
|
||||
});
|
||||
export type PrepareUploadRequest = z.infer<typeof PrepareUploadRequestSchema>;
|
||||
|
||||
export const PrepareUploadResponseSchema = z.object({
|
||||
object_id: z.string(),
|
||||
upload_url: z.string(),
|
||||
upload_headers: z.record(z.string(), z.string()),
|
||||
});
|
||||
export type PrepareUploadResponse = z.infer<typeof PrepareUploadResponseSchema>;
|
||||
|
||||
export const DepotObjectSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
content_type: z.string(),
|
||||
content_length: z.number(),
|
||||
contains_content: z.boolean(),
|
||||
created_at: z.coerce.date(),
|
||||
});
|
||||
export type DepotObject = z.infer<typeof DepotObjectSchema>;
|
||||
|
||||
// --- Particle property schemas ---
|
||||
|
||||
export const StreamPropertiesSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
|
||||
|
||||
export const FolderPropertiesSchema = z.object({
|
||||
name: z.string(),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
export type FolderProperties = z.infer<typeof FolderPropertiesSchema>;
|
||||
|
||||
const TranscriptWordSchema = z.object({
|
||||
word: z.string(),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
});
|
||||
|
||||
const TranscriptSentenceSchema = z.object({
|
||||
text: z.string(),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
});
|
||||
|
||||
const TranscriptParagraphSchema = z.object({
|
||||
sentences: z.array(TranscriptSentenceSchema),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
});
|
||||
|
||||
export const TranscriptSchema = z.object({
|
||||
transcript: z.string(),
|
||||
words: z.array(TranscriptWordSchema),
|
||||
paragraphs: z.array(TranscriptParagraphSchema),
|
||||
});
|
||||
export type Transcript = z.infer<typeof TranscriptSchema>;
|
||||
|
||||
export const MediaPropertiesSchema = z.object({
|
||||
object_id: z.string(),
|
||||
mime_type: z.string(),
|
||||
duration_ms: z.number(),
|
||||
size_bytes: z.number(),
|
||||
transcript: TranscriptSchema.optional(),
|
||||
source: z.enum(["camera", "screen"]).optional(),
|
||||
// Set by the particle processor worker once an iOS-playable MP4/m4a variant
|
||||
// has been produced from a non-iOS-playable original (e.g. WebM from desktop).
|
||||
// When present, clients should prefer these over object_id/mime_type for playback.
|
||||
transcoded_object_id: z.string().optional(),
|
||||
transcoded_mime_type: z.string().optional(),
|
||||
});
|
||||
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
|
||||
|
||||
export const FilePropertiesSchema = z.object({
|
||||
object_id: z.string(),
|
||||
filename: z.string(),
|
||||
mime_type: z.string(),
|
||||
size_bytes: z.number(),
|
||||
});
|
||||
export type FileProperties = z.infer<typeof FilePropertiesSchema>;
|
||||
|
||||
export const TextPropertiesSchema = z.object({
|
||||
content: z.string(),
|
||||
edited_at: z.coerce.date().optional(),
|
||||
});
|
||||
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
||||
|
||||
export const QuestPropertiesSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
status: z.string().optional(),
|
||||
// humanId
|
||||
assigned_to: z.string().optional(),
|
||||
});
|
||||
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
|
||||
|
||||
export const PaperPropertiesSchema = z.object({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
|
||||
|
||||
// --- Reactions ---
|
||||
|
||||
export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional();
|
||||
export type Reactions = z.infer<typeof ReactionsSchema>;
|
||||
|
||||
// --- Tombstone (soft-delete) ---
|
||||
|
||||
// Fields added to non-container particles when their creator deletes them.
|
||||
// We keep the doc around so concurrent viewers can see a "This particle was
|
||||
// deleted" message in place, rather than being jumped to the next particle.
|
||||
const TombstoneFields = {
|
||||
deleted_at: z.coerce.date().optional(),
|
||||
deleted_by_human_id: z.string().optional(),
|
||||
};
|
||||
|
||||
export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}", "\u{1F602}"] as const;
|
||||
|
||||
export interface ParticlePropertiesMap {
|
||||
stream: StreamProperties;
|
||||
folder: FolderProperties;
|
||||
media: MediaProperties;
|
||||
file: FileProperties;
|
||||
text: TextProperties;
|
||||
quest: QuestProperties;
|
||||
paper: PaperProperties;
|
||||
}
|
||||
|
||||
// --- Unified Particle types ---
|
||||
|
||||
const ParticleBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
created_at: z.coerce.date(),
|
||||
created_by_human_id: z.string(),
|
||||
updated_at: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const ParticleSchema = z.discriminatedUnion("type", [
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal("stream"),
|
||||
properties: StreamPropertiesSchema,
|
||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||
// e.g. ["network:xywx"] - visible to everyone in the network
|
||||
visible_to: z.array(z.string()),
|
||||
// Marks human_id to their `playback_position_at`: where they left off in a conversation
|
||||
playback_markers: z.record(z.string(), z.coerce.date()).optional(),
|
||||
// Timestamp of the most recent child particle
|
||||
// used for sorting streams by recent activity without needing to query subcollections
|
||||
last_child_created_at: z.coerce.date().optional(),
|
||||
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
||||
huddle_active_participants: z.array(z.string()).optional(),
|
||||
status: z.enum(["open", "closed"]).optional(),
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal("folder"), properties: FolderPropertiesSchema,
|
||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||
// e.g. ["network:123"] - visible to everyone in the network
|
||||
visible_to: z.array(z.string()),
|
||||
}),
|
||||
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }),
|
||||
]);
|
||||
|
||||
export type Particle = z.infer<typeof ParticleSchema>;
|
||||
|
||||
export type ParticleType = Particle["type"];
|
||||
|
||||
/** Container types can have children subcollections */
|
||||
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]);
|
||||
|
||||
export function isContainerType(type: ParticleType): boolean {
|
||||
return CONTAINER_TYPES.has(type);
|
||||
}
|
||||
|
||||
/** True when a non-container particle has been soft-deleted (tombstoned). */
|
||||
export function isParticleDeleted(particle: Particle): boolean {
|
||||
return "deleted_at" in particle && particle.deleted_at != null;
|
||||
}
|
||||
|
||||
// --- LiveKit types ---
|
||||
|
||||
export const GetLivekitTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
server_url: z.string(),
|
||||
});
|
||||
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>;
|
||||
|
||||
// --- Auth types ---
|
||||
|
||||
const RequestCodeRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
|
||||
|
||||
const SignInRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
code: z.string(),
|
||||
});
|
||||
export type SignInRequest = z.infer<typeof SignInRequestSchema>;
|
||||
|
||||
export const SignInResponseSchema = z.object({
|
||||
human: HumanSchema,
|
||||
token: z.string(),
|
||||
});
|
||||
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
||||
|
||||
export const FirebaseTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
|
||||
|
||||
// --- Billing types ---
|
||||
|
||||
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
||||
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
|
||||
|
||||
export const NetworkPlanSchema = z.enum(["free", "pro"]);
|
||||
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
|
||||
|
||||
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
|
||||
export const BillingPlanStatusSchema = z.enum([
|
||||
"active",
|
||||
"trialing",
|
||||
"past_due",
|
||||
"canceled",
|
||||
"incomplete",
|
||||
"incomplete_expired",
|
||||
"unpaid",
|
||||
]);
|
||||
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
|
||||
|
||||
export const BillingStatusSchema = z.object({
|
||||
plan: NetworkPlanSchema,
|
||||
plan_status: BillingPlanStatusSchema,
|
||||
cadence: BillingCadenceSchema.nullable(),
|
||||
seats: z.number().int(),
|
||||
current_period_end: z.coerce.date().nullable(),
|
||||
cancel_at_period_end: z.boolean(),
|
||||
price_monthly_cents: z.number().int(),
|
||||
price_annual_cents: z.number().int(),
|
||||
});
|
||||
export type BillingStatus = z.infer<typeof BillingStatusSchema>;
|
||||
|
||||
export const CheckoutSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
|
||||
|
||||
export const PortalSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
|
||||
|
||||
export const NetworkUsageSchema = z.object({
|
||||
plan: NetworkPlanSchema,
|
||||
used: z.number().int().nonnegative(),
|
||||
limit: z.number().int().nonnegative().nullable(),
|
||||
reset_at: z.coerce.date(),
|
||||
});
|
||||
export type NetworkUsage = z.infer<typeof NetworkUsageSchema>;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Text, View } from "react-native";
|
||||
import type { Human } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Size = "xs" | "sm" | "md";
|
||||
|
||||
interface AvatarProps {
|
||||
humanId: string | null | undefined;
|
||||
humans: Human[] | undefined;
|
||||
size?: Size;
|
||||
/** True for online presence — adds a green ring (matches desktop). */
|
||||
online?: boolean;
|
||||
/** Background ring used to separate stacked avatars from the chrome. */
|
||||
stackBg?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
|
||||
xs: { box: "h-6 w-6", text: "text-[9px]", ring: 1.5 },
|
||||
sm: { box: "h-9 w-9", text: "text-xs", ring: 2 },
|
||||
md: { box: "h-10 w-10", text: "text-sm", ring: 2 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Initials avatar with optional online ring (green) and an optional outer
|
||||
* stack ring used to visually separate overlapping avatars on a busy chrome.
|
||||
* Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`).
|
||||
*/
|
||||
export function Avatar({
|
||||
humanId,
|
||||
humans,
|
||||
size = "sm",
|
||||
online = false,
|
||||
stackBg,
|
||||
className,
|
||||
}: AvatarProps) {
|
||||
const { initials } = resolveHumanDisplay(humanId, humans);
|
||||
const dims = sizeMap[size];
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"bg-black/15 items-center justify-center rounded-full",
|
||||
dims.box,
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
// Online ring is the priority; if not online, show the stack
|
||||
// separator ring (if requested) so adjacent avatars stay distinct.
|
||||
borderWidth: online ? dims.ring : stackBg ? dims.ring : 0,
|
||||
borderColor: online ? "#22c55e" : stackBg ?? "transparent",
|
||||
}}
|
||||
>
|
||||
<Text className={cn("text-white font-semibold", dims.text)}>
|
||||
{initials}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
View,
|
||||
} from "react-native";
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Easing,
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
||||
const ANIMATION_MS = 240;
|
||||
|
||||
interface BottomSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** When true, wrap content in KeyboardAvoidingView so the sheet floats above the keyboard. */
|
||||
avoidKeyboard?: boolean;
|
||||
/**
|
||||
* Cap on the sheet's height. Defaults to 85%; pass a string like "60%" or
|
||||
* a number of px when content has a stable footprint.
|
||||
*/
|
||||
maxHeight?: number | `${number}%`;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared modal sheet shell. Handles slide-in animation, backdrop fade,
|
||||
* drag-to-dismiss, and modal-safe SafeAreaProvider seeding so iOS modals get
|
||||
* correct insets on the first frame. The drag handle at the top is rendered
|
||||
* here too, so callers don't need to draw it themselves.
|
||||
*/
|
||||
export function BottomSheet({
|
||||
open,
|
||||
onClose,
|
||||
avoidKeyboard = false,
|
||||
maxHeight = "85%",
|
||||
children,
|
||||
}: BottomSheetProps) {
|
||||
// Mount slightly past `open` so the slide-in animation has its starting
|
||||
// position rendered, and the slide-out animation can play before unmount.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const translateY = useSharedValue(SCREEN_HEIGHT);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
requestAnimationFrame(() => {
|
||||
translateY.value = withSpring(0, {
|
||||
damping: 24,
|
||||
stiffness: 260,
|
||||
mass: 0.7,
|
||||
});
|
||||
});
|
||||
} else if (mounted) {
|
||||
translateY.value = withTiming(
|
||||
SCREEN_HEIGHT,
|
||||
{ duration: ANIMATION_MS, easing: Easing.in(Easing.cubic) },
|
||||
(finished) => {
|
||||
if (finished) runOnJS(setMounted)(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const sheetPan = Gesture.Pan()
|
||||
.activeOffsetY(10)
|
||||
.failOffsetX([-25, 25])
|
||||
.onUpdate((e) => {
|
||||
"worklet";
|
||||
translateY.value = Math.max(0, e.translationY);
|
||||
})
|
||||
.onEnd((e) => {
|
||||
"worklet";
|
||||
if (e.translationY > 120 || e.velocityY > 800) {
|
||||
runOnJS(onClose)();
|
||||
} else {
|
||||
translateY.value = withSpring(0, {
|
||||
damping: 24,
|
||||
stiffness: 260,
|
||||
mass: 0.7,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sheetStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => {
|
||||
const opacity = interpolate(
|
||||
translateY.value,
|
||||
[0, SCREEN_HEIGHT * 0.7],
|
||||
[0.55, 0],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
return { opacity };
|
||||
});
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const Wrapper = avoidKeyboard ? KeyboardAvoidingView : View;
|
||||
const wrapperProps = avoidKeyboard
|
||||
? { behavior: Platform.OS === "ios" ? ("padding" as const) : undefined }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={mounted}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Animated.View
|
||||
pointerEvents={open ? "auto" : "none"}
|
||||
style={[
|
||||
{ position: "absolute", inset: 0, backgroundColor: "black" },
|
||||
backdropStyle,
|
||||
]}
|
||||
>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
|
||||
<Wrapper
|
||||
{...wrapperProps}
|
||||
style={{ flex: 1, justifyContent: "flex-end" }}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<GestureDetector gesture={sheetPan}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: "#1c1c1c",
|
||||
borderTopLeftRadius: 22,
|
||||
borderTopRightRadius: 22,
|
||||
overflow: "hidden",
|
||||
maxHeight,
|
||||
},
|
||||
sheetStyle,
|
||||
]}
|
||||
>
|
||||
<SafeAreaView edges={["bottom"]}>
|
||||
<View className="px-5 pt-3 items-center">
|
||||
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
||||
</View>
|
||||
{children}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</Wrapper>
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import type { Human } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import type { ComposingUser } from "@/features/stream-view/stream-presence-context";
|
||||
|
||||
interface ComposingIndicatorProps {
|
||||
users: ComposingUser[];
|
||||
networkHumans?: Human[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim horizontal pill stack pinned just under the metadata header. Each
|
||||
* pill is the typing/recording indicator for a single user. Rendered above
|
||||
* the particle canvas with a translucent background so it reads on any
|
||||
* media. Mobile equivalent of desktop's vertical writing-mode indicator.
|
||||
*/
|
||||
export function ComposingIndicator({
|
||||
users,
|
||||
networkHumans,
|
||||
}: ComposingIndicatorProps) {
|
||||
if (users.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View pointerEvents="none" className="flex-row flex-wrap items-center gap-1.5">
|
||||
{users.map((u) => {
|
||||
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
|
||||
const label =
|
||||
u.mode === "recording"
|
||||
? `${displayName} is recording`
|
||||
: u.mode === "screen"
|
||||
? `${displayName} is sharing`
|
||||
: `${displayName} is typing`;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={u.humanId}
|
||||
className="bg-white/15 flex-row items-center gap-1.5 rounded-full px-2 py-1"
|
||||
>
|
||||
<BouncingDots />
|
||||
<Text className="text-white/80 text-[11px] font-medium">
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BouncingDots() {
|
||||
return (
|
||||
<View className="flex-row items-end gap-0.5" style={{ height: 8 }}>
|
||||
<Dot delay={0} />
|
||||
<Dot delay={150} />
|
||||
<Dot delay={300} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ delay }: { delay: number }) {
|
||||
const y = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
const start = setTimeout(() => {
|
||||
y.value = withRepeat(
|
||||
withTiming(-3, {
|
||||
duration: 360,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
}),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
}, delay);
|
||||
return () => clearTimeout(start);
|
||||
}, [delay, y]);
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: y.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
className="bg-white/85 h-1 w-1 rounded-full"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text, type TextProps } from "react-native";
|
||||
import { formatDistanceToNow } from "@/lib/time-utils";
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
interface RelativeTimestampProps extends Omit<TextProps, "children"> {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-renders once per minute so labels like "5m ago" stay accurate without
|
||||
* any per-card timer wiring at the call site.
|
||||
*/
|
||||
export function RelativeTimestamp({ date, ...rest }: RelativeTimestampProps) {
|
||||
const [, force] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => force((n) => n + 1), MINUTE_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return <Text {...rest}>{formatDistanceToNow(date)}</Text>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Constants from "expo-constants";
|
||||
|
||||
// Expo-side equivalent of desktop's __APP_ENV__ build-time replacement
|
||||
// (see js/desktop/src/config/env.ts). On mobile we read from app.config.ts
|
||||
// `extra.appEnv`, which itself reads `process.env.EXPO_PUBLIC_APP_ENV` at
|
||||
// build time. Defaults to "dev".
|
||||
//
|
||||
// Firebase web config is public by design (security is enforced via
|
||||
// Firestore rules + App Check), so both configs live in source. To refresh,
|
||||
// run: cd infra/gcp/{dev,prod} && terraform output -json firebase_config
|
||||
|
||||
type FirebaseConfig = {
|
||||
apiKey: string;
|
||||
appId: string;
|
||||
authDomain: string;
|
||||
messagingSenderId: string;
|
||||
projectId: string;
|
||||
storageBucket: string;
|
||||
};
|
||||
|
||||
type AppConfig = {
|
||||
orionUrl: string;
|
||||
pusherUrl: string;
|
||||
firebase: FirebaseConfig;
|
||||
/** Empty string disables Sentry. Same DSN across envs; events are split by `environment` tag. */
|
||||
sentryDsn: string;
|
||||
};
|
||||
|
||||
const configs: Record<"dev" | "prod", AppConfig> = {
|
||||
dev: {
|
||||
orionUrl: "https://orion.dev.flowy.live",
|
||||
pusherUrl: "wss://pusher.dev.flowy.live/ws",
|
||||
firebase: {
|
||||
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk",
|
||||
appId: "1:1006580076785:web:e2a0736d60a78e02b15950",
|
||||
authDomain: "flowy-dev-440017.firebaseapp.com",
|
||||
messagingSenderId: "1006580076785",
|
||||
projectId: "flowy-dev-440017",
|
||||
storageBucket: "flowy-dev-440017.firebasestorage.app",
|
||||
},
|
||||
sentryDsn:
|
||||
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
|
||||
},
|
||||
prod: {
|
||||
orionUrl: "https://orion.flowy.live",
|
||||
pusherUrl: "wss://pusher.flowy.live/ws",
|
||||
firebase: {
|
||||
apiKey: "AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg",
|
||||
appId: "1:68063426854:web:5054f16f50898f5706e9e7",
|
||||
authDomain: "flowy-prod-440017.firebaseapp.com",
|
||||
messagingSenderId: "68063426854",
|
||||
projectId: "flowy-prod-440017",
|
||||
storageBucket: "flowy-prod-440017.firebasestorage.app",
|
||||
},
|
||||
sentryDsn:
|
||||
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
|
||||
},
|
||||
};
|
||||
|
||||
const rawEnv = (Constants.expoConfig?.extra as { appEnv?: string } | undefined)
|
||||
?.appEnv;
|
||||
export const appEnv: "dev" | "prod" = rawEnv === "prod" ? "prod" : "dev";
|
||||
export const appConfig: AppConfig = configs[appEnv];
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
type Step = "email" | "code";
|
||||
|
||||
export function SignInScreen() {
|
||||
const [step, setStep] = useState<Step>("email");
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<View className="flex-1 justify-center px-6">
|
||||
{step === "email" ? (
|
||||
<EmailStep
|
||||
onCodeSent={(submittedEmail) => {
|
||||
setEmail(submittedEmail);
|
||||
setStep("code");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CodeStep email={email} onBack={() => setStep("email")} />
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
|
||||
const error = useAuthStore((s) => s.error);
|
||||
const requestCode = useAuthStore((s) => s.requestCode);
|
||||
const clearError = useAuthStore((s) => s.clearError);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await requestCode(email);
|
||||
onCodeSent(email);
|
||||
} catch {
|
||||
// Error surfaced via the store
|
||||
}
|
||||
};
|
||||
|
||||
const disabled = isRequestingCode || email.trim().length === 0;
|
||||
|
||||
return (
|
||||
<View className="gap-5">
|
||||
<View className="gap-1">
|
||||
<Text className="text-foreground text-3xl font-semibold">Sign in</Text>
|
||||
<Text className="text-muted-foreground text-base">
|
||||
Enter your email to receive a sign-in code.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
<Text className="text-foreground text-sm font-medium">Email</Text>
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={(text) => {
|
||||
setEmail(text);
|
||||
if (error) clearError();
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
placeholderTextColor="#878787"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
textContentType="emailAddress"
|
||||
autoFocus
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={submit}
|
||||
className="border-input text-foreground rounded-lg border bg-background px-4 py-3 text-base"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-destructive text-sm">{error}</Text>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
onPress={submit}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg px-4 py-3.5 items-center ${
|
||||
disabled ? "bg-muted" : "bg-primary"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-base font-semibold ${
|
||||
disabled ? "text-muted-foreground" : "text-primary-foreground"
|
||||
}`}
|
||||
>
|
||||
{isRequestingCode ? "Sending..." : "Continue"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
|
||||
const [code, setCode] = useState("");
|
||||
const isSigningIn = useAuthStore((s) => s.isSigningIn);
|
||||
const error = useAuthStore((s) => s.error);
|
||||
const signIn = useAuthStore((s) => s.signIn);
|
||||
const clearError = useAuthStore((s) => s.clearError);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await signIn(email, code);
|
||||
} catch {
|
||||
// Error surfaced via the store; keep the screen visible
|
||||
}
|
||||
};
|
||||
|
||||
const disabled = isSigningIn || code.trim().length === 0;
|
||||
|
||||
return (
|
||||
<View className="gap-5">
|
||||
<View className="gap-1">
|
||||
<Text className="text-foreground text-3xl font-semibold">
|
||||
Check your email
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-base">
|
||||
We sent a code to{" "}
|
||||
<Text className="text-foreground font-medium">{email}</Text>.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
<Text className="text-foreground text-sm font-medium">Code</Text>
|
||||
<TextInput
|
||||
value={code}
|
||||
onChangeText={(text) => {
|
||||
setCode(text);
|
||||
if (error) clearError();
|
||||
}}
|
||||
placeholder="Enter code"
|
||||
placeholderTextColor="#878787"
|
||||
keyboardType="number-pad"
|
||||
autoFocus
|
||||
returnKeyType="go"
|
||||
textContentType="oneTimeCode"
|
||||
onSubmitEditing={submit}
|
||||
className="border-input text-foreground rounded-lg border bg-background px-4 py-3 text-base tracking-widest"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-destructive text-sm">{error}</Text>
|
||||
) : null}
|
||||
|
||||
<View className="gap-2">
|
||||
<Pressable
|
||||
onPress={submit}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg px-4 py-3.5 items-center ${
|
||||
disabled ? "bg-muted" : "bg-primary"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-base font-semibold ${
|
||||
disabled ? "text-muted-foreground" : "text-primary-foreground"
|
||||
}`}
|
||||
>
|
||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
className="rounded-lg px-4 py-3.5 items-center"
|
||||
>
|
||||
<Text className="text-muted-foreground text-base font-medium">
|
||||
Back
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Mic } from "lucide-react-native";
|
||||
import {
|
||||
RecordingPresets,
|
||||
setAudioModeAsync,
|
||||
useAudioRecorder,
|
||||
useAudioRecorderState,
|
||||
} from "expo-audio";
|
||||
import { logError } from "@/lib/errors";
|
||||
|
||||
const MAX_DURATION_S = 60;
|
||||
|
||||
interface AudioRecordingOverlayProps {
|
||||
onComplete: (result: { uri: string; durationMs: number }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function AudioRecordingOverlay({
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: AudioRecordingOverlayProps) {
|
||||
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||
const state = useAudioRecorderState(recorder, 250);
|
||||
const finalizedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: true,
|
||||
playsInSilentMode: true,
|
||||
});
|
||||
await recorder.prepareToRecordAsync();
|
||||
if (!active) return;
|
||||
recorder.record();
|
||||
} catch (err) {
|
||||
logError(err, { scope: "compose.audio.start" });
|
||||
if (active) onCancel();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (!finalizedRef.current) {
|
||||
finalizedRef.current = true;
|
||||
recorder.stop().catch(() => {});
|
||||
}
|
||||
void setAudioModeAsync({
|
||||
allowsRecording: false,
|
||||
playsInSilentMode: true,
|
||||
}).catch((err) => logError(err, { scope: "compose.audio.exit" }));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const elapsedMs = state.durationMillis ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
|
||||
void finish("commit");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elapsedMs]);
|
||||
|
||||
const finish = async (kind: "commit" | "cancel") => {
|
||||
if (finalizedRef.current) return;
|
||||
finalizedRef.current = true;
|
||||
const durationMs = state.durationMillis ?? 0;
|
||||
try {
|
||||
await recorder.stop();
|
||||
} catch (err) {
|
||||
logError(err, { scope: "compose.audio.stop" });
|
||||
}
|
||||
if (kind === "cancel") {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
const uri = recorder.uri;
|
||||
if (!uri) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
onComplete({ uri, durationMs });
|
||||
};
|
||||
|
||||
const elapsedSec = Math.floor(elapsedMs / 1000);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={StyleSheet.absoluteFill}
|
||||
className="bg-black items-center justify-center px-8"
|
||||
>
|
||||
<View className="bg-red-500/30 h-32 w-32 items-center justify-center rounded-full">
|
||||
<View className="bg-red-500/60 h-24 w-24 items-center justify-center rounded-full">
|
||||
<Mic color="white" size={42} strokeWidth={1.5} />
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-semibold">
|
||||
{state.isRecording ? "Recording" : "Starting…"}
|
||||
</Text>
|
||||
<Text className="text-white/60 mt-1 text-sm">
|
||||
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s
|
||||
</Text>
|
||||
|
||||
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
|
||||
<Pressable
|
||||
onPress={() => void finish("cancel")}
|
||||
accessibilityLabel="Cancel recording"
|
||||
className="rounded-full bg-white/15 px-6 py-3"
|
||||
>
|
||||
<Text className="text-white text-base font-medium">Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void finish("commit")}
|
||||
accessibilityLabel="Stop recording"
|
||||
className="rounded-full bg-white px-7 py-3"
|
||||
>
|
||||
<Text className="text-black text-base font-semibold">Stop</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import {
|
||||
useCameraPermissions,
|
||||
useMicrophonePermissions,
|
||||
} from "expo-camera";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import {
|
||||
createTextParticle,
|
||||
uploadMediaParticle,
|
||||
} from "@/lib/upload";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import {
|
||||
useStreamComposingBroadcast,
|
||||
type ComposingMode,
|
||||
} from "@/features/stream-view/stream-presence-context";
|
||||
import { TextComposeModal } from "./TextComposeModal";
|
||||
import { VideoRecordingOverlay } from "./VideoRecordingOverlay";
|
||||
import { AudioRecordingOverlay } from "./AudioRecordingOverlay";
|
||||
import { ReviewSheet } from "./ReviewSheet";
|
||||
|
||||
type RecordingMode = "video" | "audio";
|
||||
|
||||
type ComposeUiState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "recording"; mode: RecordingMode }
|
||||
| {
|
||||
kind: "review";
|
||||
mode: RecordingMode;
|
||||
uri: string;
|
||||
durationMs: number;
|
||||
}
|
||||
| { kind: "uploading" };
|
||||
|
||||
interface SubmitMediaParams {
|
||||
fileUri: string;
|
||||
mimeType: string;
|
||||
durationMs: number;
|
||||
source: "camera" | "screen";
|
||||
}
|
||||
|
||||
interface ComposeDockProps {
|
||||
networkId: string;
|
||||
targetPath: ParticlePath;
|
||||
silentPresence?: boolean;
|
||||
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
|
||||
submitText?: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ComposeDock({
|
||||
networkId,
|
||||
targetPath,
|
||||
silentPresence = false,
|
||||
submitMedia,
|
||||
submitText: submitTextOverride,
|
||||
}: ComposeDockProps) {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const [mode, setMode] = useState<RecordingMode>("video");
|
||||
const [ui, setUi] = useState<ComposeUiState>({ kind: "idle" });
|
||||
const [textOpen, setTextOpen] = useState(false);
|
||||
|
||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
||||
|
||||
// Tell StreamView to fully unmount its expo-video player while we record.
|
||||
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
||||
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
||||
const isComposing = ui.kind !== "idle" || textOpen;
|
||||
useEffect(() => {
|
||||
setComposing(isComposing);
|
||||
return () => setComposing(false);
|
||||
}, [isComposing, setComposing]);
|
||||
|
||||
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
|
||||
|
||||
const ensurePermissions = useCallback(
|
||||
async (forVideo: boolean): Promise<boolean> => {
|
||||
if (forVideo) {
|
||||
const cam = camPerm?.granted ? camPerm : await requestCamPerm();
|
||||
if (!cam.granted) {
|
||||
toast.error("Camera permission is required to record video.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const mic = micPerm?.granted ? micPerm : await requestMicPerm();
|
||||
if (!mic.granted) {
|
||||
toast.error("Microphone permission is required to record.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[camPerm, micPerm, requestCamPerm, requestMicPerm],
|
||||
);
|
||||
|
||||
const startRecording = useEvent(async () => {
|
||||
if (ui.kind !== "idle") return;
|
||||
const ok = await ensurePermissions(mode === "video");
|
||||
if (!ok) return;
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
setUi({ kind: "recording", mode });
|
||||
});
|
||||
|
||||
const handleRecordingComplete = useCallback(
|
||||
({ uri, durationMs }: { uri: string; durationMs: number }) => {
|
||||
void Haptics.selectionAsync();
|
||||
setUi((prev) => {
|
||||
const m = "mode" in prev ? prev.mode : mode;
|
||||
return { kind: "review", mode: m, uri, durationMs };
|
||||
});
|
||||
},
|
||||
[mode],
|
||||
);
|
||||
|
||||
const handleRecordingCancel = useCallback(() => {
|
||||
setUi({ kind: "idle" });
|
||||
}, []);
|
||||
|
||||
const sendReview = useEvent(async () => {
|
||||
if (ui.kind !== "review" || !userId) return;
|
||||
const captured = ui;
|
||||
setUi({ kind: "uploading" });
|
||||
try {
|
||||
const mimeType =
|
||||
captured.mode === "audio" ? "audio/mp4" : "video/mp4";
|
||||
if (submitMedia) {
|
||||
await submitMedia({
|
||||
fileUri: captured.uri,
|
||||
mimeType,
|
||||
durationMs: captured.durationMs,
|
||||
source: "camera",
|
||||
});
|
||||
} else {
|
||||
await uploadMediaParticle({
|
||||
networkId,
|
||||
targetPath,
|
||||
fileUri: captured.uri,
|
||||
mimeType,
|
||||
durationMs: captured.durationMs,
|
||||
source: "camera",
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
}
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setUi({ kind: "idle" });
|
||||
} catch (err) {
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
setUi(captured);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
const retake = useCallback(() => setUi({ kind: "idle" }), []);
|
||||
const cancelReview = useCallback(() => setUi({ kind: "idle" }), []);
|
||||
|
||||
const submitText = useEvent(async (content: string) => {
|
||||
if (!userId) throw new Error("Not signed in.");
|
||||
if (submitTextOverride) {
|
||||
await submitTextOverride(content);
|
||||
} else {
|
||||
await createTextParticle({
|
||||
networkId,
|
||||
targetPath,
|
||||
content,
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
}
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
});
|
||||
|
||||
const dockHidden =
|
||||
ui.kind === "review" ||
|
||||
ui.kind === "uploading" ||
|
||||
ui.kind === "recording";
|
||||
|
||||
return (
|
||||
<>
|
||||
{!dockHidden ? (
|
||||
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
className="flex-row items-center justify-between px-8 pb-10"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setMode((m) => (m === "video" ? "audio" : "video"))
|
||||
}
|
||||
disabled={ui.kind !== "idle"}
|
||||
accessibilityLabel={`Switch to ${
|
||||
mode === "video" ? "audio" : "video"
|
||||
} mode`}
|
||||
className={cn(
|
||||
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
||||
ui.kind !== "idle" && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{mode === "video" ? (
|
||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||
) : (
|
||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View className="items-center">
|
||||
<Pressable
|
||||
onPress={startRecording}
|
||||
disabled={ui.kind !== "idle"}
|
||||
accessibilityLabel={`Record ${mode}`}
|
||||
className="h-20 w-20 items-center justify-center rounded-full bg-white"
|
||||
>
|
||||
<View className="h-6 w-6 rounded bg-black" />
|
||||
</Pressable>
|
||||
<Text className="text-white/60 mt-2 text-xs">
|
||||
Tap to record
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setTextOpen(true)}
|
||||
disabled={ui.kind !== "idle"}
|
||||
accessibilityLabel="Compose text"
|
||||
className={cn(
|
||||
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
||||
ui.kind !== "idle" && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{ui.kind === "recording" ? (
|
||||
ui.mode === "video" ? (
|
||||
<VideoRecordingOverlay
|
||||
onComplete={handleRecordingComplete}
|
||||
onCancel={handleRecordingCancel}
|
||||
/>
|
||||
) : (
|
||||
<AudioRecordingOverlay
|
||||
onComplete={handleRecordingComplete}
|
||||
onCancel={handleRecordingCancel}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<ReviewSheet
|
||||
open={ui.kind === "review"}
|
||||
uri={ui.kind === "review" ? ui.uri : null}
|
||||
mode={ui.kind === "review" ? ui.mode : null}
|
||||
durationMs={ui.kind === "review" ? ui.durationMs : 0}
|
||||
onSend={sendReview}
|
||||
onRetake={retake}
|
||||
onCancel={cancelReview}
|
||||
/>
|
||||
|
||||
<TextComposeModal
|
||||
open={textOpen}
|
||||
onClose={() => setTextOpen(false)}
|
||||
onSubmit={submitText}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useComposingBroadcast({
|
||||
ui,
|
||||
textOpen,
|
||||
silent,
|
||||
}: {
|
||||
ui: ComposeUiState;
|
||||
textOpen: boolean;
|
||||
silent: boolean;
|
||||
}) {
|
||||
let broadcast: ReturnType<typeof useStreamComposingBroadcast> | null;
|
||||
try {
|
||||
broadcast = useStreamComposingBroadcast();
|
||||
} catch {
|
||||
broadcast = null;
|
||||
}
|
||||
|
||||
const mode: ComposingMode | null =
|
||||
ui.kind === "recording" ? "recording" : textOpen ? "typing" : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (silent || !broadcast) return;
|
||||
if (mode) {
|
||||
broadcast.startComposing(mode);
|
||||
return () => broadcast?.stopComposing();
|
||||
}
|
||||
}, [mode, silent, broadcast]);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { Mic } from "lucide-react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
|
||||
interface ReviewSheetProps {
|
||||
open: boolean;
|
||||
/** Local file URI from the recorder. */
|
||||
uri: string | null;
|
||||
mode: "video" | "audio" | null;
|
||||
durationMs: number;
|
||||
onSend: () => Promise<void>;
|
||||
onRetake: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop-plays the just-recorded clip and offers Retake / Send. WhatsApp-style
|
||||
* confirmation: any send-failure surfaces a toast and keeps the sheet open
|
||||
* so the user doesn't lose their take.
|
||||
*/
|
||||
export function ReviewSheet({
|
||||
open,
|
||||
uri,
|
||||
mode,
|
||||
durationMs,
|
||||
onSend,
|
||||
onRetake,
|
||||
onCancel,
|
||||
}: ReviewSheetProps) {
|
||||
const player = useVideoPlayer(uri ?? "", (p) => {
|
||||
p.loop = true;
|
||||
p.muted = false;
|
||||
p.audioMixingMode = "mixWithOthers";
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && uri) {
|
||||
player.play();
|
||||
}
|
||||
}, [open, uri, player]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSend();
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const seconds = Math.max(1, Math.round(durationMs / 1000));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
animationType="fade"
|
||||
transparent={false}
|
||||
onRequestClose={onCancel}
|
||||
>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<View className="flex-1 bg-black">
|
||||
{uri ? (
|
||||
mode === "audio" ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View className="bg-white/10 h-28 w-28 items-center justify-center rounded-full">
|
||||
<Mic color="white" size={42} strokeWidth={1.5} />
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-semibold">
|
||||
Voice message · {seconds}s
|
||||
</Text>
|
||||
<Text className="text-white/50 mt-2 text-sm">
|
||||
Tap send to share, or retake.
|
||||
</Text>
|
||||
<View className="absolute" style={{ width: 1, height: 1 }}>
|
||||
<VideoView
|
||||
style={{ width: 1, height: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<VideoView
|
||||
style={{ flex: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
contentFit="cover"
|
||||
allowsFullscreen={false}
|
||||
allowsPictureInPicture={false}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<SafeAreaView
|
||||
edges={["top"]}
|
||||
className="absolute top-0 left-0 right-0"
|
||||
>
|
||||
<View className="px-4 pt-3">
|
||||
<Pressable
|
||||
onPress={onCancel}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Cancel"
|
||||
>
|
||||
<Text className="text-white/80 text-base">Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
className="absolute bottom-0 left-0 right-0"
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-6 pb-4 pt-3">
|
||||
<Pressable
|
||||
onPress={onRetake}
|
||||
disabled={submitting}
|
||||
className="rounded-full bg-white/15 px-5 py-3"
|
||||
accessibilityLabel="Retake"
|
||||
>
|
||||
<Text className="text-white text-base font-medium">Retake</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={handleSend}
|
||||
disabled={submitting}
|
||||
className={cn(
|
||||
"rounded-full px-7 py-3",
|
||||
submitting ? "bg-white/40" : "bg-white",
|
||||
)}
|
||||
accessibilityLabel="Send"
|
||||
>
|
||||
<Text className="text-black text-base font-semibold">
|
||||
{submitting ? "Sending..." : "Send"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function getImmersiveStyle(length: number) {
|
||||
if (length === 0)
|
||||
return { className: "text-3xl font-semibold leading-snug" };
|
||||
if (length < 30)
|
||||
return { className: "text-5xl font-semibold leading-tight" };
|
||||
if (length < 70)
|
||||
return { className: "text-3xl font-semibold leading-snug" };
|
||||
return { className: "text-2xl font-normal leading-snug" };
|
||||
}
|
||||
|
||||
interface TextComposeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Submit handler — must throw if the upload fails so the modal can re-show
|
||||
* the editor and the user doesn't lose their text.
|
||||
*/
|
||||
onSubmit: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immersive full-screen text editor. Mirrors desktop's `text-editor.tsx`:
|
||||
* dynamic font ramp at 30/70/130 chars, no markdown preview, no gradient.
|
||||
* Long messages scroll inside the multiline TextInput. Pauses upstream
|
||||
* playback (the host wraps render in a useSuspendPlayback while open).
|
||||
*/
|
||||
export function TextComposeModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: TextComposeModalProps) {
|
||||
const [content, setContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Reset whenever the modal opens fresh.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setContent("");
|
||||
setSubmitting(false);
|
||||
// Re-focus on next tick; iOS occasionally drops the autoFocus call when
|
||||
// the modal animation is mid-flight.
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 60);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const trimmed = content.trim();
|
||||
const canSend = trimmed.length > 0 && !submitting;
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!canSend) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const style = getImmersiveStyle(trimmed.length);
|
||||
const isImmersive = trimmed.length < IMMERSIVE_CHAR_LIMIT;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
animationType="fade"
|
||||
transparent={false}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-4 py-3">
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
accessibilityLabel="Cancel"
|
||||
hitSlop={12}
|
||||
>
|
||||
<Text className="text-white/70 text-base">Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleSend}
|
||||
disabled={!canSend}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Send"
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-semibold",
|
||||
canSend ? "text-white" : "text-white/30",
|
||||
)}
|
||||
>
|
||||
{submitting ? "Sending..." : "Send"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 justify-center px-6 pb-6">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Type a message"
|
||||
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||
multiline
|
||||
autoFocus
|
||||
autoCorrect
|
||||
autoCapitalize="sentences"
|
||||
editable={!submitting}
|
||||
scrollEnabled={!isImmersive}
|
||||
textAlignVertical={isImmersive ? "center" : "top"}
|
||||
style={{
|
||||
color: "white",
|
||||
textAlign: isImmersive ? "center" : "left",
|
||||
maxHeight: isImmersive ? undefined : 540,
|
||||
}}
|
||||
className={cn("text-white", style.className)}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { CameraView } from "expo-camera";
|
||||
import { logError } from "@/lib/errors";
|
||||
|
||||
const MAX_DURATION_S = 60;
|
||||
|
||||
interface VideoRecordingOverlayProps {
|
||||
onComplete: (result: { uri: string; durationMs: number }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function VideoRecordingOverlay({
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: VideoRecordingOverlayProps) {
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [cameraReady, setCameraReady] = useState(false);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startRecording = async () => {
|
||||
const cam = cameraRef.current;
|
||||
if (!cam || recording || !cameraReady) return;
|
||||
|
||||
setRecording(true);
|
||||
startedAtRef.current = Date.now();
|
||||
|
||||
let result: { uri: string } | undefined;
|
||||
try {
|
||||
result = await cam.recordAsync({ maxDuration: MAX_DURATION_S });
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
logError(err, { scope: "compose.video.recordAsync" });
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
const durationMs =
|
||||
startedAtRef.current !== null ? Date.now() - startedAtRef.current : 0;
|
||||
if (result?.uri) {
|
||||
onComplete({ uri: result.uri, durationMs });
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
cameraRef.current?.stopRecording();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
cancelledRef.current = true;
|
||||
if (recording) {
|
||||
cameraRef.current?.stopRecording();
|
||||
}
|
||||
onCancel();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
const interval = setInterval(() => {
|
||||
if (startedAtRef.current === null) return;
|
||||
setElapsedMs(Date.now() - startedAtRef.current);
|
||||
}, 250);
|
||||
return () => clearInterval(interval);
|
||||
}, [recording]);
|
||||
|
||||
const elapsedSec = Math.floor(elapsedMs / 1000);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} className="bg-black">
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={StyleSheet.absoluteFill}
|
||||
facing="front"
|
||||
mode="video"
|
||||
mute={false}
|
||||
onCameraReady={() => setCameraReady(true)}
|
||||
/>
|
||||
|
||||
{recording ? (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="absolute top-0 left-0 right-0 items-center pt-16"
|
||||
>
|
||||
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
|
||||
<View className="h-2 w-2 rounded-full bg-white" />
|
||||
<Text className="text-white text-xs font-semibold tracking-wide">
|
||||
REC · {elapsedSec.toString().padStart(2, "0")}s
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
|
||||
<Pressable
|
||||
onPress={cancel}
|
||||
accessibilityLabel="Cancel"
|
||||
className="rounded-full bg-white/15 px-6 py-3"
|
||||
>
|
||||
<Text className="text-white text-base font-medium">Cancel</Text>
|
||||
</Pressable>
|
||||
{recording ? (
|
||||
<Pressable
|
||||
onPress={stopRecording}
|
||||
accessibilityLabel="Stop recording"
|
||||
className="rounded-full bg-white px-7 py-3"
|
||||
>
|
||||
<Text className="text-black text-base font-semibold">Stop</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={startRecording}
|
||||
disabled={!cameraReady}
|
||||
accessibilityLabel="Start recording"
|
||||
className={
|
||||
cameraReady
|
||||
? "h-20 w-20 items-center justify-center rounded-full bg-white"
|
||||
: "h-20 w-20 items-center justify-center rounded-full bg-white/40"
|
||||
}
|
||||
>
|
||||
<View className="h-16 w-16 rounded-full bg-red-500" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get("window").width;
|
||||
const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82));
|
||||
const ANIM_MS = 220;
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onNavigateAccount: () => void;
|
||||
onNavigateSettings: () => void;
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
onNavigateAccount,
|
||||
}: DrawerProps) {
|
||||
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(translateX, {
|
||||
toValue: open ? 0 : -DRAWER_WIDTH,
|
||||
duration: ANIM_MS,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backdropOpacity, {
|
||||
toValue: open ? 0.4 : 0,
|
||||
duration: ANIM_MS,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, [open, translateX, backdropOpacity]);
|
||||
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const signOut = useAuthStore((s) => s.signOut);
|
||||
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
||||
|
||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
{/* Modal mounts a separate native view tree on iOS — without a fresh
|
||||
SafeAreaProvider seeded with initialWindowMetrics, useSafeAreaInsets
|
||||
inside reports {0,0,0,0} on the first frame and content snaps from
|
||||
the status bar down to the safe area once metrics resolve. */}
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<View className="flex-1">
|
||||
<Animated.View
|
||||
pointerEvents={open ? "auto" : "none"}
|
||||
style={{ opacity: backdropOpacity }}
|
||||
className="absolute inset-0 bg-black"
|
||||
>
|
||||
<Pressable className="flex-1" onPress={onClose} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={{
|
||||
width: DRAWER_WIDTH,
|
||||
transform: [{ translateX }],
|
||||
}}
|
||||
className="absolute left-0 top-0 bottom-0 bg-sidebar"
|
||||
>
|
||||
<SafeAreaView edges={["top", "bottom", "left"]} className="flex-1">
|
||||
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
|
||||
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
||||
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
||||
{initials}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-sidebar-foreground text-base font-medium"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{user?.email_prefix ?? ""}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-muted-foreground text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{user?.email ?? ""}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 py-2">
|
||||
<DrawerRow
|
||||
label="Account"
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onNavigateAccount();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="border-sidebar-border border-t px-2 py-2">
|
||||
<DrawerRow
|
||||
label={isSigningOut ? "Signing out..." : "Sign out"}
|
||||
disabled={isSigningOut}
|
||||
onPress={() => {
|
||||
void signOut();
|
||||
}}
|
||||
tone="destructive"
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerRow({
|
||||
label,
|
||||
onPress,
|
||||
disabled,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
className="px-5 py-3 active:bg-sidebar-accent"
|
||||
>
|
||||
<Text
|
||||
className={`text-base font-medium ${
|
||||
tone === "destructive"
|
||||
? "text-destructive"
|
||||
: "text-sidebar-foreground"
|
||||
} ${disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import type { Network } from "@/api/types";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { Drawer } from "./Drawer";
|
||||
|
||||
export function NetworkListScreen({
|
||||
navigation,
|
||||
}: RootStackScreenProps<"NetworkList">) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const { data, isLoading, refetch, error } = useNetworks();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
// Local refreshing state — driving RefreshControl from react-query's
|
||||
// isRefetching can leave the native spinner visually stuck after the
|
||||
// screen is detached/reattached by native-stack.
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refetch();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
||||
<Pressable
|
||||
onPress={() => setDrawerOpen(true)}
|
||||
accessibilityLabel="Open menu"
|
||||
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
<Text className="text-muted-foreground text-xs font-semibold">
|
||||
{initials}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text className="text-foreground text-base font-semibold">Flowy</Text>
|
||||
<View className="w-9" />
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-destructive text-center">
|
||||
{toUserMessage(error)}
|
||||
</Text>
|
||||
<Pressable onPress={() => refetch()} className="mt-3 px-4 py-2">
|
||||
<Text className="text-foreground font-medium">Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : !data || data.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<FlatList
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerClassName="p-4 gap-2"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<NetworkCard
|
||||
network={item}
|
||||
onPress={() =>
|
||||
navigation.navigate("StreamList", { networkId: item.id })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onNavigateAccount={() => navigation.navigate("Account")}
|
||||
onNavigateSettings={() => navigation.navigate("Settings")}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkCard({
|
||||
network,
|
||||
onPress,
|
||||
}: {
|
||||
network: Network;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className="bg-card border-border active:bg-accent rounded-lg border px-4 py-4 flex-row items-center justify-between"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-card-foreground text-base font-semibold">
|
||||
{network.name}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{network.humans.length}{" "}
|
||||
{network.humans.length === 1 ? "member" : "members"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-muted-foreground text-xl">›</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-foreground text-lg font-medium text-center">
|
||||
You aren't in any networks yet.
|
||||
</Text>
|
||||
<Text className="text-muted-foreground mt-2 text-center">
|
||||
Ask a friend for an invite, or create one on desktop.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
|
||||
export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||
<Text className="text-foreground text-2xl">‹</Text>
|
||||
</Pressable>
|
||||
<Text className="flex-1 text-center text-foreground text-base font-semibold">
|
||||
Account
|
||||
</Text>
|
||||
<View className="w-8" />
|
||||
</View>
|
||||
|
||||
<View className="px-6 py-6 gap-4">
|
||||
<Field label="Email" value={user?.email ?? "—"} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View className="gap-1">
|
||||
<Text className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{label}
|
||||
</Text>
|
||||
<Text className="text-foreground text-base">{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
|
||||
export function SettingsScreen({
|
||||
navigation,
|
||||
}: RootStackScreenProps<"Settings">) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||
<Text className="text-foreground text-2xl">‹</Text>
|
||||
</Pressable>
|
||||
<Text className="flex-1 text-center text-foreground text-base font-semibold">
|
||||
Settings
|
||||
</Text>
|
||||
<View className="w-8" />
|
||||
</View>
|
||||
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Theme, notifications, and account preferences land here later.
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Trash2 } from "lucide-react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
|
||||
// How long to linger on a tombstone before auto-advancing. Same cadence as
|
||||
// desktop — a beat long enough to read "this was deleted," not so long it
|
||||
// stalls the stream.
|
||||
const TOMBSTONE_DURATION_MS = 2000;
|
||||
|
||||
interface DeletedParticleViewProps {
|
||||
particle: Particle;
|
||||
networkId: string;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
export function DeletedParticleView({
|
||||
particle,
|
||||
networkId,
|
||||
paused,
|
||||
onEnded,
|
||||
}: DeletedParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const deleterId =
|
||||
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
|
||||
const deleter = deleterId
|
||||
? resolveHumanDisplay(deleterId, network?.humans)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const timeout = setTimeout(onEnded, TOMBSTONE_DURATION_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [paused, onEnded, particle.id]);
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Trash2 color="rgba(255,255,255,0.4)" size={28} strokeWidth={1.5} />
|
||||
<Text className="text-white/70 mt-3 text-base font-medium">
|
||||
This particle was deleted
|
||||
</Text>
|
||||
{deleter ? (
|
||||
<Text className="text-white/40 mt-1 text-xs">
|
||||
by {deleter.displayName}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import {
|
||||
FileIcon,
|
||||
HelpCircle,
|
||||
ScrollText,
|
||||
BookOpen,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
|
||||
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
|
||||
quest: { icon: ScrollText, label: "Quest" },
|
||||
paper: { icon: BookOpen, label: "Paper" },
|
||||
file: { icon: FileIcon, label: "File" },
|
||||
};
|
||||
|
||||
const PLACEHOLDER_DURATION_MS = 5000;
|
||||
|
||||
interface FallbackParticleViewProps {
|
||||
particle: Particle;
|
||||
networkId: string;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
export function FallbackParticleView({
|
||||
particle,
|
||||
networkId,
|
||||
paused,
|
||||
onEnded,
|
||||
}: FallbackParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const creator = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const meta = TYPE_META[particle.type] ?? {
|
||||
icon: HelpCircle,
|
||||
label: particle.type,
|
||||
};
|
||||
const Icon = meta.icon;
|
||||
const title = (() => {
|
||||
switch (particle.type) {
|
||||
case "quest":
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
return particle.properties.title;
|
||||
case "file":
|
||||
return particle.properties.filename;
|
||||
case "folder":
|
||||
return particle.properties.name;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const timeout = setTimeout(onEnded, PLACEHOLDER_DURATION_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [paused, onEnded, particle.id]);
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View className="bg-white/10 w-full max-w-sm rounded-2xl px-5 py-5">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
||||
<View className="flex-1">
|
||||
<Text className="text-white text-base font-semibold">
|
||||
{meta.label}
|
||||
</Text>
|
||||
{title ? (
|
||||
<Text className="text-white/70 text-sm" numberOfLines={2}>
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-white/50 mt-4 text-xs">
|
||||
From {creator.displayName}
|
||||
</Text>
|
||||
<Text className="text-white/50 mt-1 text-xs">View on desktop</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import { Mic, Video as VideoIcon } from "lucide-react-native";
|
||||
import { useEventListener } from "expo";
|
||||
import { useVideoPlayer, VideoView, type VideoPlayerStatus } from "expo-video";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { logError } from "@/lib/errors";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
|
||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||
|
||||
interface MediaParticleViewProps {
|
||||
particle: MediaParticle;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
/** "cover" fills the screen (may crop); "contain" fits the whole frame. */
|
||||
contentFit?: "cover" | "contain";
|
||||
}
|
||||
|
||||
const TICK_MS = 150;
|
||||
|
||||
/**
|
||||
* Plays MP4 / MOV / m4a content via expo-video. Desktop currently records
|
||||
* WebM, which AVPlayer can't decode; the particle processor worker produces
|
||||
* an iOS-playable MP4/m4a variant and writes `transcoded_object_id` /
|
||||
* `transcoded_mime_type` to the particle. While that work is in flight, we
|
||||
* show a placeholder and let the Firestore listener
|
||||
* swap us into the playable state once the worker finishes.
|
||||
*
|
||||
* The signed download URL is fetched lazily via apiClient.getParticleDownloadUrl
|
||||
* (Orion-issued, time-limited). We show a spinner while that resolves, then
|
||||
* mount the player and report progress via a 150ms tick reading the player's
|
||||
* currentTime — same model as desktop's MediaParticleView.
|
||||
*/
|
||||
export function MediaParticleView({
|
||||
particle,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
contentFit = "cover",
|
||||
}: MediaParticleViewProps) {
|
||||
const activeObjectId =
|
||||
particle.properties.transcoded_object_id ?? particle.properties.object_id;
|
||||
const activeMime =
|
||||
particle.properties.transcoded_mime_type ?? particle.properties.mime_type;
|
||||
const isAudio = activeMime.startsWith("audio/");
|
||||
const isPlayable = isPlayableMime(activeMime);
|
||||
|
||||
// Reset progress as the active particle changes — independent of playback
|
||||
// state — so the segmented bar drops back to 0 immediately.
|
||||
useEffect(() => {
|
||||
onProgress(0);
|
||||
}, [particle.id, onProgress]);
|
||||
|
||||
if (!isPlayable) {
|
||||
return <ProcessingForMobilePlaceholder isAudio={isAudio} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PlayableMediaView
|
||||
particle={particle}
|
||||
activeObjectId={activeObjectId}
|
||||
isAudio={isAudio}
|
||||
paused={paused}
|
||||
onEnded={onEnded}
|
||||
onProgress={onProgress}
|
||||
contentFit={contentFit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayableMediaView({
|
||||
particle,
|
||||
activeObjectId,
|
||||
isAudio,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
contentFit,
|
||||
}: {
|
||||
particle: MediaParticle;
|
||||
activeObjectId: string;
|
||||
isAudio: boolean;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
contentFit: "cover" | "contain";
|
||||
}) {
|
||||
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
||||
const [resolveError, setResolveError] = useState<Error | null>(null);
|
||||
|
||||
// Fetch the signed download URL once per active object. Orion URLs are
|
||||
// time-limited — we treat the URL as one-shot for this view's lifetime.
|
||||
// Re-runs when the worker writes `transcoded_object_id` and the parent
|
||||
// resolves a new active object id.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSourceUri(null);
|
||||
setResolveError(null);
|
||||
apiClient
|
||||
.getParticleDownloadUrl(activeObjectId)
|
||||
.then((url) => {
|
||||
if (!cancelled) setSourceUri(url);
|
||||
})
|
||||
.catch((err) => {
|
||||
logError(err, { scope: "media.download-url" });
|
||||
if (!cancelled) setResolveError(err as Error);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeObjectId, particle.id]);
|
||||
|
||||
const player = useVideoPlayer(sourceUri ?? "", (p) => {
|
||||
p.loop = false;
|
||||
p.muted = false;
|
||||
p.timeUpdateEventInterval = 0.15;
|
||||
// Don't take exclusive ownership of the iOS AVAudioSession. Without this
|
||||
// the player blocks expo-camera from acquiring the session for video
|
||||
// recording (audio works because expo-audio deactivates other sessions
|
||||
// natively before claiming the session).
|
||||
p.audioMixingMode = "mixWithOthers";
|
||||
});
|
||||
|
||||
// Drive play/pause from the suspender store. The player itself is forgiving
|
||||
// about extra play/pause calls so we don't gate this.
|
||||
useEffect(() => {
|
||||
if (!sourceUri) return;
|
||||
if (paused) {
|
||||
player.pause();
|
||||
} else {
|
||||
player.play();
|
||||
}
|
||||
}, [paused, sourceUri, player]);
|
||||
|
||||
// End-of-clip → advance. We listen to status flips rather than computing
|
||||
// duration ratios because video duration may be 0 for the first frame or two.
|
||||
useEventListener(player, "statusChange", ({ status }) => {
|
||||
if (status === ("idle" satisfies VideoPlayerStatus)) {
|
||||
// ignored — happens during source swap
|
||||
}
|
||||
});
|
||||
|
||||
const onEndedStable = useEvent(onEnded);
|
||||
const onProgressStable = useEvent(onProgress);
|
||||
|
||||
// Progress tick: report currentTime / duration each TICK_MS. Bail when the
|
||||
// player isn't ready yet (duration = 0).
|
||||
useEffect(() => {
|
||||
if (!sourceUri || paused) return;
|
||||
const interval = setInterval(() => {
|
||||
const duration = player.duration;
|
||||
if (!duration || duration <= 0) return;
|
||||
const ratio = Math.min(player.currentTime / duration, 1);
|
||||
onProgressStable(ratio);
|
||||
if (ratio >= 0.999) {
|
||||
clearInterval(interval);
|
||||
onEndedStable();
|
||||
}
|
||||
}, TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [sourceUri, paused, player, onEndedStable, onProgressStable]);
|
||||
|
||||
if (resolveError) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className="text-white/80 text-base text-center">
|
||||
Couldn't load this {isAudio ? "voice message" : "video"}.
|
||||
</Text>
|
||||
<Text className="text-white/50 text-sm text-center mt-2">
|
||||
Tap forward to continue.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sourceUri) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
<ActivityIndicator color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio-only: hide the (blank) video surface and show a static face. The
|
||||
// VideoView still renders 0×0 so the audio track keeps playing.
|
||||
if (isAudio) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View
|
||||
className="absolute"
|
||||
style={{ width: 0, height: 0, opacity: 0 }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<VideoView
|
||||
style={{ width: 1, height: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
allowsFullscreen={false}
|
||||
allowsPictureInPicture={false}
|
||||
/>
|
||||
</View>
|
||||
<View className="bg-white/10 h-24 w-24 items-center justify-center rounded-full">
|
||||
<Mic color="white" size={36} strokeWidth={1.5} />
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-medium">
|
||||
Voice message
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Video: full-bleed. Default cover so portrait mobile captures fill the
|
||||
// screen; the user can flip to contain via the top-right toggle when desktop
|
||||
// captures at odd aspect ratios get cropped uncomfortably.
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
<VideoView
|
||||
style={{ flex: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
contentFit={contentFit}
|
||||
allowsFullscreen={false}
|
||||
allowsPictureInPicture={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Shown while the particle processor worker is producing the iOS-playable
|
||||
// MP4/m4a variant. The Firestore listener will re-render this view once
|
||||
// `transcoded_object_id` lands on the particle, which swaps us into
|
||||
// PlayableMediaView. We deliberately do not auto-advance — the user is here
|
||||
// to consume this content; if the worker is slow they can tap forward.
|
||||
function ProcessingForMobilePlaceholder({ isAudio }: { isAudio: boolean }) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View className="bg-white/10 h-24 w-24 items-center justify-center rounded-full">
|
||||
{isAudio ? (
|
||||
<Mic color="white" size={36} strokeWidth={1.5} />
|
||||
) : (
|
||||
<VideoIcon color="white" size={36} strokeWidth={1.5} />
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-medium">
|
||||
{isAudio ? "Voice message" : "Video message"}
|
||||
</Text>
|
||||
<View className="flex-row items-center mt-3">
|
||||
<Text className="text-white/60 ml-3 text-sm">
|
||||
View on desktop
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-white/40 mt-2 text-xs text-center">
|
||||
Please view this on desktop only.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function isPlayableMime(mime: string): boolean {
|
||||
// expo-video uses AVPlayer on iOS — reliable for h264 in mp4 / mov / m4a.
|
||||
// WebM/VP9 (the legacy desktop format) is not decodable.
|
||||
return (
|
||||
mime === "video/mp4" ||
|
||||
mime === "video/quicktime" ||
|
||||
mime === "audio/mp4" ||
|
||||
mime === "audio/aac" ||
|
||||
mime === "audio/x-m4a" ||
|
||||
mime === "audio/mpeg"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface PlaybackPageIndicatorProps {
|
||||
total: number;
|
||||
current: number;
|
||||
/** 0–1 progress for the active segment. Source ticks at ~100ms. */
|
||||
progress: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
const SEGMENT_GAP = 3;
|
||||
const SEGMENT_HEIGHT = 2.5;
|
||||
const SMOOTHING_MS = 300;
|
||||
|
||||
/**
|
||||
* Snapchat-style segmented progress bar. Past segments full, future empty,
|
||||
* active segment animated. The 300ms linear smoothing absorbs the 100ms
|
||||
* tick from the particle view source so motion looks continuous at 60fps.
|
||||
*/
|
||||
export function PlaybackPageIndicator({
|
||||
total,
|
||||
current,
|
||||
progress,
|
||||
paused,
|
||||
}: PlaybackPageIndicatorProps) {
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-row items-stretch" style={{ gap: SEGMENT_GAP }}>
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<Segment
|
||||
key={i}
|
||||
isActive={i === current}
|
||||
isPast={i < current}
|
||||
progress={progress}
|
||||
paused={paused}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SegmentProps {
|
||||
isActive: boolean;
|
||||
isPast: boolean;
|
||||
progress: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
function Segment({ isActive, isPast, progress, paused }: SegmentProps) {
|
||||
// Each segment owns its own width animation. Past = 1, future = 0,
|
||||
// active = animated toward `progress`. Reanimated keeps the tween on the
|
||||
// UI thread so JS thread stalls (e.g. the 100ms text tick re-render)
|
||||
// can't drop frames here.
|
||||
const fill = useSharedValue(isPast ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPast) {
|
||||
cancelAnimation(fill);
|
||||
fill.value = withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) });
|
||||
return;
|
||||
}
|
||||
if (!isActive) {
|
||||
cancelAnimation(fill);
|
||||
fill.value = 0;
|
||||
return;
|
||||
}
|
||||
if (paused) {
|
||||
cancelAnimation(fill);
|
||||
return;
|
||||
}
|
||||
fill.value = withTiming(progress, {
|
||||
duration: SMOOTHING_MS,
|
||||
easing: Easing.linear,
|
||||
});
|
||||
}, [isPast, isActive, progress, paused, fill]);
|
||||
|
||||
const fillStyle = useAnimatedStyle(() => ({
|
||||
width: `${Math.min(Math.max(fill.value, 0), 1) * 100}%`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 overflow-hidden rounded-full bg-white/30"
|
||||
style={{ height: SEGMENT_HEIGHT }}
|
||||
>
|
||||
<Animated.View
|
||||
className="h-full bg-white/95 rounded-full"
|
||||
style={fillStyle}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from "react-native-safe-area-context";
|
||||
import { Send, X } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
} from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Easing,
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||
import type { Human } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
|
||||
const TEXT_REACTION_MAX = 40;
|
||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
||||
const ANIMATION_MS = 240;
|
||||
|
||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||
|
||||
interface ReactionSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
reactions: Reactions;
|
||||
currentHumanId: string;
|
||||
humans: Human[] | undefined;
|
||||
/**
|
||||
* Toggle a reaction (emoji or text). Adds if the current human hasn't
|
||||
* reacted, removes if they have. Mirrors desktop's `onToggle` exactly.
|
||||
*/
|
||||
onToggle: (key: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-up reaction sheet — the mobile replacement for desktop's right-edge
|
||||
* reaction stack. Tap an emoji to toggle, or send a custom text reaction
|
||||
* (40-char cap). Existing reactions appear as toggleable pills at the top.
|
||||
*
|
||||
* Playback is suspended via `useSuspendPlayback` while the sheet is open so
|
||||
* the active particle doesn't auto-advance under the user. Drag the sheet
|
||||
* down past 30% of its travel to dismiss; everything else springs back.
|
||||
*/
|
||||
export function ReactionSheet({
|
||||
open,
|
||||
onClose,
|
||||
reactions,
|
||||
currentHumanId,
|
||||
humans,
|
||||
onToggle,
|
||||
}: ReactionSheetProps) {
|
||||
// Suspend playback whenever the sheet is mounted-and-open. The Modal
|
||||
// controls visibility so we tie the suspender to `open` directly.
|
||||
useSuspendPlayback(open, "reactions-sheet");
|
||||
|
||||
// We mount the modal slightly delayed from `open` so the slide-up animation
|
||||
// has its starting position rendered. Using local `mounted` state lets us
|
||||
// play the close animation before unmounting.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const translateY = useSharedValue(SCREEN_HEIGHT);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
// Schedule animation after the modal mounts
|
||||
requestAnimationFrame(() => {
|
||||
translateY.value = withSpring(0, {
|
||||
damping: 24,
|
||||
stiffness: 260,
|
||||
mass: 0.7,
|
||||
});
|
||||
});
|
||||
} else if (mounted) {
|
||||
translateY.value = withTiming(
|
||||
SCREEN_HEIGHT,
|
||||
{ duration: ANIMATION_MS, easing: Easing.in(Easing.cubic) },
|
||||
(finished) => {
|
||||
if (finished) runOnJS(setMounted)(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
// intentional: only react to `open`. Closing animation reads from `mounted`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const dismiss = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const sheetPan = Gesture.Pan()
|
||||
.activeOffsetY(10)
|
||||
.failOffsetX([-25, 25])
|
||||
.onUpdate((e) => {
|
||||
"worklet";
|
||||
translateY.value = Math.max(0, e.translationY);
|
||||
})
|
||||
.onEnd((e) => {
|
||||
"worklet";
|
||||
if (e.translationY > 120 || e.velocityY > 800) {
|
||||
runOnJS(dismiss)();
|
||||
} else {
|
||||
translateY.value = withSpring(0, {
|
||||
damping: 24,
|
||||
stiffness: 260,
|
||||
mass: 0.7,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sheetStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => {
|
||||
const opacity = interpolate(
|
||||
translateY.value,
|
||||
[0, SCREEN_HEIGHT * 0.7],
|
||||
[0.55, 0],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
return { opacity };
|
||||
});
|
||||
|
||||
// --- Existing reaction pills ---
|
||||
const activeEmojis = REACTION_EMOJIS.filter(
|
||||
(e) => reactions?.[e] && (reactions[e]?.length ?? 0) > 0,
|
||||
);
|
||||
const activeTextKeys = useMemo(
|
||||
() =>
|
||||
Object.keys(reactions ?? {}).filter(
|
||||
(k) =>
|
||||
!EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
|
||||
),
|
||||
[reactions],
|
||||
);
|
||||
|
||||
// --- Text reaction input ---
|
||||
const [text, setText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setText("");
|
||||
}, [open]);
|
||||
|
||||
const submitText = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
void Haptics.selectionAsync();
|
||||
onToggle(trimmed.slice(0, TEXT_REACTION_MAX));
|
||||
setText("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEmoji = (emoji: string) => {
|
||||
void Haptics.selectionAsync();
|
||||
onToggle(emoji);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={mounted}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={dismiss}
|
||||
>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Animated.View
|
||||
pointerEvents={open ? "auto" : "none"}
|
||||
style={[
|
||||
{ position: "absolute", inset: 0, backgroundColor: "black" },
|
||||
backdropStyle,
|
||||
]}
|
||||
>
|
||||
<Pressable style={{ flex: 1 }} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1, justifyContent: "flex-end" }}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<GestureDetector gesture={sheetPan}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: "#1c1c1c",
|
||||
borderTopLeftRadius: 22,
|
||||
borderTopRightRadius: 22,
|
||||
overflow: "hidden",
|
||||
},
|
||||
sheetStyle,
|
||||
]}
|
||||
>
|
||||
<SafeAreaView edges={["bottom"]}>
|
||||
<View className="px-5 pt-3 pb-2 items-center">
|
||||
{/* Drag handle — affords downward dismissal at a glance. */}
|
||||
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
||||
|
||||
<View className="flex-row items-center justify-between w-full">
|
||||
<Text className="text-white text-base font-semibold">
|
||||
React
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={dismiss}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Close reactions"
|
||||
>
|
||||
<X color="rgba(255,255,255,0.6)" size={20} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Existing reactions row — tap a pill to toggle yours. */}
|
||||
{activeEmojis.length > 0 || activeTextKeys.length > 0 ? (
|
||||
<View className="px-5 pb-3 flex-row flex-wrap gap-2">
|
||||
{activeEmojis.map((emoji) => {
|
||||
const reactors = reactions![emoji];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
return (
|
||||
<Pressable
|
||||
key={emoji}
|
||||
onPress={() => handleEmoji(emoji)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1.5 rounded-full px-3 py-1.5",
|
||||
isMine
|
||||
? "bg-white/25 border border-white/40"
|
||||
: "bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Text className="text-base">{emoji}</Text>
|
||||
<Text className="text-white/85 text-xs font-semibold">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeTextKeys.map((key) => {
|
||||
const reactors = reactions![key];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
const firstReactor = resolveHumanDisplay(
|
||||
reactors[0],
|
||||
humans,
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => handleEmoji(key)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1.5 rounded-full px-2.5 py-1.5 max-w-[220px]",
|
||||
isMine
|
||||
? "bg-white/25 border border-white/40"
|
||||
: "bg-white/10",
|
||||
)}
|
||||
>
|
||||
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
|
||||
<Text className="text-white text-[9px] font-semibold">
|
||||
{firstReactor.initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-white/90 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{key}
|
||||
</Text>
|
||||
{reactors.length > 1 ? (
|
||||
<Text className="text-white/60 text-xs font-medium">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Quick-pick emoji palette — six big tappable buttons. */}
|
||||
<View className="px-3 pt-2 pb-4 flex-row justify-around">
|
||||
{REACTION_EMOJIS.slice(0, 6).map((emoji) => {
|
||||
const isMine =
|
||||
reactions?.[emoji]?.includes(currentHumanId) ?? false;
|
||||
return (
|
||||
<Pressable
|
||||
key={emoji}
|
||||
onPress={() => handleEmoji(emoji)}
|
||||
accessibilityLabel={`React with ${emoji}`}
|
||||
className={cn(
|
||||
"h-14 w-14 items-center justify-center rounded-full",
|
||||
isMine ? "bg-white/25" : "bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Text style={{ fontSize: 28 }}>{emoji}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Text reaction input — 40-char cap matches desktop. */}
|
||||
<View className="px-4 pb-4 flex-row items-center gap-2">
|
||||
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={(v) => setText(v.slice(0, TEXT_REACTION_MAX))}
|
||||
placeholder="Send a quick reply..."
|
||||
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||
maxLength={TEXT_REACTION_MAX}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onSubmitEditing={submitText}
|
||||
returnKeyType="send"
|
||||
className="text-white text-base"
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={submitText}
|
||||
disabled={text.trim().length === 0}
|
||||
accessibilityLabel="Send text reaction"
|
||||
className={cn(
|
||||
"h-11 w-11 items-center justify-center rounded-full",
|
||||
text.trim().length === 0
|
||||
? "bg-white/10"
|
||||
: "bg-white",
|
||||
)}
|
||||
>
|
||||
<Send
|
||||
color={text.trim().length === 0 ? "rgba(255,255,255,0.3)" : "black"}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||
import type { Human } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||
|
||||
interface ReactionStackProps {
|
||||
reactions: Reactions;
|
||||
currentHumanId: string;
|
||||
humans: Human[] | undefined;
|
||||
/** Toggle a reaction (emoji or text) — same contract as ReactionSheet's onToggle. */
|
||||
onToggle: (key: string) => void;
|
||||
/** Open the full reaction sheet for emoji + custom-text picking. */
|
||||
onOpenSheet: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-edge reaction stack — mobile counterpart of desktop's ReactionBar.
|
||||
* Sits vertically centered on the right side of the canvas so the user can
|
||||
* see existing reactions at a glance and tap to toggle their own. The "+"
|
||||
* affordance opens the ReactionSheet for the full picker (emoji or text).
|
||||
*/
|
||||
export function ReactionStack({
|
||||
reactions,
|
||||
currentHumanId,
|
||||
humans,
|
||||
onToggle,
|
||||
onOpenSheet,
|
||||
}: ReactionStackProps) {
|
||||
const activeEmojis = REACTION_EMOJIS.filter(
|
||||
(emoji) => reactions?.[emoji] && (reactions[emoji]?.length ?? 0) > 0,
|
||||
);
|
||||
const activeTextKeys = useMemo(
|
||||
() =>
|
||||
Object.keys(reactions ?? {}).filter(
|
||||
(k) => !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
|
||||
),
|
||||
[reactions],
|
||||
);
|
||||
|
||||
const handleToggle = (key: string) => {
|
||||
void Haptics.selectionAsync();
|
||||
onToggle(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="items-end gap-1.5">
|
||||
{activeEmojis.map((emoji) => {
|
||||
const reactors = reactions?.[emoji] ?? [];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
return (
|
||||
<Pressable
|
||||
key={emoji}
|
||||
onPress={() => handleToggle(emoji)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1 rounded-full px-2 py-1",
|
||||
isMine ? "bg-white/25" : "bg-black/45",
|
||||
)}
|
||||
style={
|
||||
isMine
|
||||
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Text className="text-sm">{emoji}</Text>
|
||||
<Text className="text-white/85 text-xs font-medium">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeTextKeys.map((text) => {
|
||||
const reactors = reactions?.[text] ?? [];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
||||
return (
|
||||
<Pressable
|
||||
key={text}
|
||||
onPress={() => handleToggle(text)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5",
|
||||
isMine ? "bg-white/25" : "bg-black/45",
|
||||
)}
|
||||
style={[
|
||||
{ maxWidth: 200 },
|
||||
isMine
|
||||
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
||||
: null,
|
||||
]}
|
||||
>
|
||||
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
|
||||
<Text className="text-white text-[9px] font-semibold">
|
||||
{firstReactor.initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-white/90 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
{reactors.length > 1 ? (
|
||||
<Text className="text-white/60 text-xs">{reactors.length}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
<Pressable
|
||||
onPress={onOpenSheet}
|
||||
accessibilityLabel="Add reaction"
|
||||
className="h-8 w-8 items-center justify-center rounded-full bg-black/45"
|
||||
>
|
||||
<Plus color="rgba(255,255,255,0.85)" size={16} strokeWidth={2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { updateParticleProperties } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
|
||||
interface RenameStreamSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
networkId: string;
|
||||
streamId: string;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
export function RenameStreamSheet({
|
||||
open,
|
||||
onClose,
|
||||
networkId,
|
||||
streamId,
|
||||
currentName,
|
||||
}: RenameStreamSheetProps) {
|
||||
useSuspendPlayback(open, "rename-stream");
|
||||
|
||||
const [name, setName] = useState(currentName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(currentName);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [open, currentName]);
|
||||
|
||||
const trimmed = name.trim();
|
||||
const canSave = !saving && trimmed.length > 0 && trimmed !== currentName;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [streamId]));
|
||||
await updateParticleProperties<"stream">(docPath, { name: trimmed });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose} avoidKeyboard>
|
||||
<View className="flex-row items-center justify-between px-5 pb-3">
|
||||
<Pressable onPress={onClose} hitSlop={12}>
|
||||
<Text className="text-white/70 text-base">Cancel</Text>
|
||||
</Pressable>
|
||||
<Text className="text-white text-base font-semibold">Rename</Text>
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={!canSave}
|
||||
hitSlop={12}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-semibold",
|
||||
canSave ? "text-white" : "text-white/30",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="px-5 pb-6">
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
placeholder="Stream name"
|
||||
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg"
|
||||
/>
|
||||
</View>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import {
|
||||
CircleCheckBig,
|
||||
CircleDot,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
|
||||
export type StreamActionId =
|
||||
| "toggle-status"
|
||||
| "rename"
|
||||
| "members"
|
||||
| "delete-particle";
|
||||
|
||||
interface StreamActionsSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (action: StreamActionId) => void;
|
||||
streamStatus: "open" | "closed";
|
||||
isCreator: boolean;
|
||||
/** True when the *current* particle is one this user can soft-delete. */
|
||||
canDeleteParticle: boolean;
|
||||
}
|
||||
|
||||
export function StreamActionsSheet({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
streamStatus,
|
||||
isCreator,
|
||||
canDeleteParticle,
|
||||
}: StreamActionsSheetProps) {
|
||||
const choose = (id: StreamActionId) => {
|
||||
onClose();
|
||||
onSelect(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose}>
|
||||
<View className="py-2">
|
||||
<ActionRow
|
||||
icon={
|
||||
streamStatus === "open" ? (
|
||||
<CircleCheckBig color="white" size={20} />
|
||||
) : (
|
||||
<CircleDot color="#22c55e" size={20} />
|
||||
)
|
||||
}
|
||||
label={
|
||||
streamStatus === "open" ? "Close stream" : "Reopen stream"
|
||||
}
|
||||
onPress={() => choose("toggle-status")}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<Users color="white" size={20} />}
|
||||
label="Members"
|
||||
onPress={() => choose("members")}
|
||||
/>
|
||||
{isCreator ? (
|
||||
<ActionRow
|
||||
icon={<Pencil color="white" size={20} />}
|
||||
label="Rename stream"
|
||||
onPress={() => choose("rename")}
|
||||
/>
|
||||
) : null}
|
||||
{canDeleteParticle ? (
|
||||
<ActionRow
|
||||
icon={<Trash2 color="#ef4444" size={20} />}
|
||||
label="Delete particle"
|
||||
tone="destructive"
|
||||
onPress={() => choose("delete-particle")}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="px-5 pt-2 pb-2">
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
className="bg-white/10 active:bg-white/15 rounded-xl py-3 items-center"
|
||||
>
|
||||
<Text className="text-white text-base font-semibold">Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
tone = "default",
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
tone?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className="px-5 py-3.5 flex-row items-center gap-3 active:bg-white/5"
|
||||
>
|
||||
<View className="w-6 items-center">{icon}</View>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base",
|
||||
tone === "destructive" ? "text-red-400" : "text-white",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Globe, Lock, X } from "lucide-react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
|
||||
import {
|
||||
buildCustomVisibility,
|
||||
buildNetworkVisibility,
|
||||
parseVisibleTo,
|
||||
} from "@/lib/stream-visibility";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
|
||||
interface StreamMembersSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
isCreator: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only-for-non-creators view of who can see the stream, plus an inline
|
||||
* editor for creators to flip between network-wide and per-person and to
|
||||
* add/remove people. Mobile counterpart of stream-members-overlay.tsx.
|
||||
*/
|
||||
export function StreamMembersSheet({
|
||||
open,
|
||||
onClose,
|
||||
networkId,
|
||||
streamParticle,
|
||||
isCreator,
|
||||
}: StreamMembersSheetProps) {
|
||||
useSuspendPlayback(open, "stream-members");
|
||||
|
||||
const { onlineHumanIds } = useStreamPresence();
|
||||
const network = useNetwork(networkId);
|
||||
const humans = network?.humans ?? [];
|
||||
const creatorId = streamParticle.created_by_human_id;
|
||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||
|
||||
const docPath = useMemo(
|
||||
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
||||
[networkId, streamParticle.id],
|
||||
);
|
||||
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const memberSet = new Set(memberIds);
|
||||
const availableToAdd = humans.filter((h) => !memberSet.has(h.id));
|
||||
|
||||
const apply = async (next: string[]) => {
|
||||
try {
|
||||
await updateParticleVisibleTo(docPath, next);
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const setNetworkWide = () => apply(buildNetworkVisibility(networkId));
|
||||
const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId]));
|
||||
|
||||
const removeMember = (id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
if (id === creatorId) return;
|
||||
const next = visibility.humanIds.filter((x) => x !== id);
|
||||
if (next.length === 0) return;
|
||||
void apply(buildCustomVisibility(next));
|
||||
};
|
||||
|
||||
const addMember = (id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
void apply(buildCustomVisibility([...visibility.humanIds, id]));
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose} maxHeight="85%">
|
||||
<View className="flex-row items-center justify-between px-5 pb-3">
|
||||
<View style={{ width: 22 }} />
|
||||
<Text className="text-white text-base font-semibold">Members</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12}>
|
||||
<X color="rgba(255,255,255,0.7)" size={22} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="px-5 pb-3">
|
||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
|
||||
Visibility
|
||||
</Text>
|
||||
{isCreator ? (
|
||||
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
||||
<ModePill
|
||||
active={visibility.mode === "network"}
|
||||
icon={<Globe color="white" size={14} />}
|
||||
label="Network-wide"
|
||||
onPress={setNetworkWide}
|
||||
/>
|
||||
<ModePill
|
||||
active={visibility.mode === "custom"}
|
||||
icon={<Lock color="white" size={14} />}
|
||||
label="Specific people"
|
||||
onPress={setCustomOnlyCreator}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row items-center gap-2">
|
||||
{visibility.mode === "network" ? (
|
||||
<>
|
||||
<Globe color="rgba(255,255,255,0.5)" size={14} />
|
||||
<Text className="text-white/70 text-sm">
|
||||
Everyone in {network?.name ?? "network"}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock color="rgba(255,255,255,0.5)" size={14} />
|
||||
<Text className="text-white/70 text-sm">
|
||||
{memberIds.length} specific{" "}
|
||||
{memberIds.length === 1 ? "person" : "people"}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerClassName="pb-4">
|
||||
<View className="px-5 pt-2">
|
||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
|
||||
{visibility.mode === "network" ? "Has access" : "People"} ·{" "}
|
||||
{memberIds.length}
|
||||
</Text>
|
||||
{memberIds.map((id) => {
|
||||
const display = resolveHumanDisplay(id, humans);
|
||||
const isCreatorRow = id === creatorId;
|
||||
const canRemove =
|
||||
isCreator && visibility.mode === "custom" && !isCreatorRow;
|
||||
return (
|
||||
<View
|
||||
key={id}
|
||||
className="flex-row items-center gap-3 py-2.5"
|
||||
>
|
||||
<Avatar
|
||||
humanId={id}
|
||||
humans={humans}
|
||||
size="sm"
|
||||
online={onlineHumanIds.has(id)}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={
|
||||
display.exists
|
||||
? "text-white text-sm font-medium"
|
||||
: "text-white/50 italic text-sm font-medium"
|
||||
}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
{display.exists ? (
|
||||
<Text
|
||||
className="text-white/40 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.email}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{isCreatorRow ? (
|
||||
<Text className="text-white/30 text-[10px] uppercase tracking-wider">
|
||||
Creator
|
||||
</Text>
|
||||
) : canRemove ? (
|
||||
<Pressable
|
||||
onPress={() => removeMember(id)}
|
||||
hitSlop={10}
|
||||
accessibilityLabel={`Remove ${display.displayName}`}
|
||||
>
|
||||
<X color="rgba(255,255,255,0.6)" size={18} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{isCreator &&
|
||||
visibility.mode === "custom" &&
|
||||
availableToAdd.length > 0 ? (
|
||||
<View className="px-5 pt-4 mt-2 border-t border-white/5">
|
||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mt-3 mb-2">
|
||||
Add people
|
||||
</Text>
|
||||
{availableToAdd.map((human) => {
|
||||
const display = resolveHumanDisplay(human.id, humans);
|
||||
return (
|
||||
<Pressable
|
||||
key={human.id}
|
||||
onPress={() => addMember(human.id)}
|
||||
className="flex-row items-center gap-3 py-2.5 active:bg-white/5 rounded-lg"
|
||||
>
|
||||
<Avatar
|
||||
humanId={human.id}
|
||||
humans={humans}
|
||||
size="sm"
|
||||
online={onlineHumanIds.has(human.id)}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-white text-sm font-medium"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-white/40 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.email}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-white/60 text-sm">Add</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function ModePill({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={
|
||||
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2 " +
|
||||
(active ? "bg-white/15" : "")
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<Text
|
||||
className={
|
||||
active
|
||||
? "text-white text-xs font-semibold"
|
||||
: "text-white/60 text-xs"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Text, View } from "react-native";
|
||||
import type { Network, Particle } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
|
||||
interface StreamMetadataHeaderProps {
|
||||
particle: Particle | null;
|
||||
network: Network | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar + display name + relative time. Sits below the segmented bar so the
|
||||
* "who/when" answer is always one glance away — Snapchat-style.
|
||||
*/
|
||||
export function StreamMetadataHeader({
|
||||
particle,
|
||||
network,
|
||||
}: StreamMetadataHeaderProps) {
|
||||
const { onlineHumanIds } = useStreamPresence();
|
||||
if (!particle) return null;
|
||||
const display = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
|
||||
const editedAt =
|
||||
particle.type === "text" ? particle.properties.edited_at : undefined;
|
||||
const isOnline = particle.created_by_human_id
|
||||
? onlineHumanIds.has(particle.created_by_human_id)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Avatar
|
||||
humanId={particle.created_by_human_id}
|
||||
humans={network?.humans}
|
||||
size="sm"
|
||||
online={isOnline}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-white text-sm font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<RelativeTimestamp
|
||||
date={particle.created_at}
|
||||
className="text-white/60 text-xs"
|
||||
/>
|
||||
{editedAt ? (
|
||||
<Text className="text-white/40 text-xs">
|
||||
· edited{" "}
|
||||
<RelativeTimestamp date={editedAt} className="text-white/40" />
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { EllipsisVertical, Globe, Maximize2, Minimize2 } from "lucide-react-native";
|
||||
import type { Human, Particle } from "@/api/types";
|
||||
import { parseVisibleTo } from "@/lib/stream-visibility";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
|
||||
interface StreamTopActionsProps {
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
humans: Human[];
|
||||
videoFit: "cover" | "contain";
|
||||
onToggleVideoFit: () => void;
|
||||
onOpenMembers: () => void;
|
||||
onOpenActions: () => void;
|
||||
/** True when current particle is a video — fit toggle hidden otherwise. */
|
||||
showFitToggle: boolean;
|
||||
}
|
||||
|
||||
const MAX_AVATARS = 3;
|
||||
|
||||
/**
|
||||
* Top-right cluster on StreamView: visibility avatars (with presence ring),
|
||||
* fit/fill toggle, and actions menu trigger. Mirrors desktop's stream-top-bar
|
||||
* but compact for the mobile chrome.
|
||||
*/
|
||||
export function StreamTopActions({
|
||||
networkId,
|
||||
streamParticle,
|
||||
humans,
|
||||
videoFit,
|
||||
onToggleVideoFit,
|
||||
onOpenMembers,
|
||||
onOpenActions,
|
||||
showFitToggle,
|
||||
}: StreamTopActionsProps) {
|
||||
const { onlineHumanIds } = useStreamPresence();
|
||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const shown = memberIds.slice(0, MAX_AVATARS);
|
||||
const overflow = memberIds.length - shown.length;
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Pressable
|
||||
onPress={onOpenMembers}
|
||||
accessibilityLabel="Stream members"
|
||||
className="bg-white/10 active:bg-white/20 rounded-full px-2 py-1 flex-row items-center gap-1"
|
||||
>
|
||||
{visibility.mode === "network" && memberIds.length === 0 ? (
|
||||
<Globe color="rgba(255,255,255,0.85)" size={14} />
|
||||
) : (
|
||||
<View className="flex-row">
|
||||
{shown.map((id, idx) => (
|
||||
<View
|
||||
key={id}
|
||||
style={{ marginLeft: idx === 0 ? 0 : -8 }}
|
||||
>
|
||||
{/* The stack ring matches the chrome's translucent bg so it
|
||||
reads as a separator without painting hard black halos. */}
|
||||
<Avatar
|
||||
humanId={id}
|
||||
humans={humans}
|
||||
size="xs"
|
||||
online={onlineHumanIds.has(id)}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{overflow > 0 ? (
|
||||
<Text className="text-white/70 text-[10px] font-medium ml-0.5">
|
||||
+{overflow}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
|
||||
{showFitToggle ? (
|
||||
<Pressable
|
||||
onPress={onToggleVideoFit}
|
||||
accessibilityLabel={
|
||||
videoFit === "cover" ? "Fit video to screen" : "Fill screen with video"
|
||||
}
|
||||
className={cn(
|
||||
"h-8 w-8 items-center justify-center rounded-full",
|
||||
"bg-white/10 active:bg-white/20",
|
||||
)}
|
||||
>
|
||||
{videoFit === "cover" ? (
|
||||
<Minimize2 color="white" size={15} strokeWidth={1.8} />
|
||||
) : (
|
||||
<Maximize2 color="white" size={15} strokeWidth={1.8} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
onPress={onOpenActions}
|
||||
accessibilityLabel="More actions"
|
||||
className="h-8 w-8 items-center justify-center rounded-full bg-white/10 active:bg-white/20"
|
||||
>
|
||||
<EllipsisVertical color="white" size={16} strokeWidth={1.8} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, Dimensions, Pressable, Text, View } from "react-native";
|
||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
} from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
||||
import {
|
||||
parseParticlePath,
|
||||
particlePath,
|
||||
toFirestoreDocPath,
|
||||
type ParticlePath,
|
||||
} from "@/lib/particle-path";
|
||||
import {
|
||||
softDeleteParticle,
|
||||
toggleParticleReaction,
|
||||
updateStreamStatus,
|
||||
} from "@/lib/firestore-particles";
|
||||
import { toast } from "sonner-native";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import {
|
||||
selectIsComposing,
|
||||
selectIsPaused,
|
||||
usePlaybackPauseStore,
|
||||
} from "@/stores/playback-pause-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { ComposeDock } from "@/features/compose/ComposeDock";
|
||||
import { ComposingIndicator } from "@/components/ComposingIndicator";
|
||||
import { PlaybackPageIndicator } from "./PlaybackPageIndicator";
|
||||
import { ReactionSheet } from "./ReactionSheet";
|
||||
import { StreamMetadataHeader } from "./StreamMetadataHeader";
|
||||
import { StreamSafeAreaProvider } from "./stream-safe-area";
|
||||
import {
|
||||
StreamPresenceProvider,
|
||||
useStreamComposing,
|
||||
} from "./stream-presence-context";
|
||||
import { TextParticleView } from "./TextParticleView";
|
||||
import { MediaParticleView } from "./MediaParticleView";
|
||||
import { DeletedParticleView } from "./DeletedParticleView";
|
||||
import { FallbackParticleView } from "./FallbackParticleView";
|
||||
import { useExitCountdown } from "./use-exit-countdown";
|
||||
import { StreamTopActions } from "./StreamTopActions";
|
||||
import { StreamActionsSheet, type StreamActionId } from "./StreamActionsSheet";
|
||||
import { StreamMembersSheet } from "./StreamMembersSheet";
|
||||
import { RenameStreamSheet } from "./RenameStreamSheet";
|
||||
import { ReactionStack } from "./ReactionStack";
|
||||
|
||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
||||
// Tap-zone split: left 28% goes back, right 72% goes forward — matching the
|
||||
// asymmetric "Snapchat thumb-zone" so right-handed taps default to forward.
|
||||
const PREV_ZONE_RATIO = 0.28;
|
||||
// Swipe-down dismiss commit thresholds — either move 1/4 of the screen, or
|
||||
// flick downward fast enough.
|
||||
const DISMISS_DISTANCE = SCREEN_HEIGHT * 0.25;
|
||||
const DISMISS_VELOCITY = 900;
|
||||
// Swipe-up reactions commit thresholds — flick up ~80px or with enough velocity.
|
||||
const REACTIONS_DISTANCE = 80;
|
||||
const REACTIONS_VELOCITY = 600;
|
||||
// Approx height of the ComposeDock from the screen bottom (record button stack
|
||||
// + pb-10). Status pills sit just above this so they aren't hidden behind it.
|
||||
const COMPOSE_DOCK_HEIGHT = 50;
|
||||
|
||||
interface StreamViewProps {
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
path: ParticlePath;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function StreamView(props: StreamViewProps) {
|
||||
const { networkId } = parseParticlePath(props.path);
|
||||
// The presence provider wraps the inner view so any descendant can broadcast
|
||||
// composing state without re-deriving the channel id.
|
||||
return (
|
||||
<StreamPresenceProvider
|
||||
networkId={networkId}
|
||||
streamId={props.streamParticle.id}
|
||||
>
|
||||
<StreamViewInner {...props} />
|
||||
</StreamPresenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
const { networkId } = parseParticlePath(path);
|
||||
const network = useNetwork(networkId);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { composingUsers } = useStreamComposing();
|
||||
|
||||
const { children, currentParticle, currentIndex, status, next, prev } =
|
||||
useStreamPlayback(streamParticle, path);
|
||||
|
||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||
const composing = usePlaybackPauseStore(selectIsComposing);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||
|
||||
// Local hold state drives the "touch-hold" pause suspender. We wrap the JS
|
||||
// setter inside a runOnJS callback dispatched from the worklet thread.
|
||||
const [holdActive, setHoldActive] = useState(false);
|
||||
useSuspendPlayback(holdActive, "touch-hold");
|
||||
|
||||
// Reaction sheet — opens via swipe-up on the canvas.
|
||||
const [reactionsOpen, setReactionsOpen] = useState(false);
|
||||
|
||||
// Top-right cluster sheet state. `videoFit` lets the user toggle expo-video's
|
||||
// contentFit for the active media particle when desktop captures of unusual
|
||||
// aspect ratios get cropped uncomfortably under the default `cover` mode.
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [membersOpen, setMembersOpen] = useState(false);
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [videoFit, setVideoFit] = useState<"cover" | "contain">("cover");
|
||||
|
||||
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
||||
const canDeleteCurrentParticle =
|
||||
!!currentParticle &&
|
||||
!!userId &&
|
||||
currentParticle.created_by_human_id === userId &&
|
||||
currentParticle.type !== "stream" &&
|
||||
currentParticle.type !== "folder" &&
|
||||
!isParticleDeleted(currentParticle);
|
||||
|
||||
const showFitToggle =
|
||||
!!currentParticle &&
|
||||
!isParticleDeleted(currentParticle) &&
|
||||
currentParticle.type === "media" &&
|
||||
!currentParticle.properties.mime_type.startsWith("audio/");
|
||||
|
||||
const handleStreamAction = useCallback(
|
||||
async (action: StreamActionId) => {
|
||||
const streamDocPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id]),
|
||||
);
|
||||
switch (action) {
|
||||
case "toggle-status": {
|
||||
try {
|
||||
await updateStreamStatus(
|
||||
streamDocPath,
|
||||
streamParticle.status === "open" ? "closed" : "open",
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "rename":
|
||||
setRenameOpen(true);
|
||||
return;
|
||||
case "members":
|
||||
setMembersOpen(true);
|
||||
return;
|
||||
case "delete-particle": {
|
||||
if (!currentParticle || !userId) return;
|
||||
if (!canDeleteCurrentParticle) return;
|
||||
Alert.alert(
|
||||
"Delete this particle?",
|
||||
"This cannot be undone. Other viewers will see a \"deleted\" message in its place.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [
|
||||
streamParticle.id,
|
||||
currentParticle.id,
|
||||
]),
|
||||
);
|
||||
await softDeleteParticle(docPath, userId);
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
networkId,
|
||||
streamParticle.id,
|
||||
streamParticle.status,
|
||||
currentParticle,
|
||||
userId,
|
||||
canDeleteCurrentParticle,
|
||||
],
|
||||
);
|
||||
|
||||
const reactionsOnCurrent =
|
||||
currentParticle && !isParticleDeleted(currentParticle)
|
||||
? currentParticle.type === "media" || currentParticle.type === "text"
|
||||
? currentParticle.reactions
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
const handleToggleReaction = useCallback(
|
||||
(key: string) => {
|
||||
if (!userId || !currentParticle) return;
|
||||
if (isParticleDeleted(currentParticle)) return;
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id, currentParticle.id]),
|
||||
);
|
||||
void toggleParticleReaction(
|
||||
docPath,
|
||||
key,
|
||||
userId,
|
||||
reactionsOnCurrent,
|
||||
);
|
||||
},
|
||||
[userId, currentParticle, networkId, streamParticle.id, reactionsOnCurrent],
|
||||
);
|
||||
|
||||
const openReactions = useCallback(() => setReactionsOpen(true), []);
|
||||
|
||||
// Reset progress whenever the active particle changes.
|
||||
useEffect(() => {
|
||||
setProgress(0);
|
||||
}, [currentParticle?.id]);
|
||||
|
||||
const handleTap = useCallback(
|
||||
(xRatio: number) => {
|
||||
if (xRatio < PREV_ZONE_RATIO) {
|
||||
if (currentIndex <= 0) {
|
||||
// Soft "thud" — nothing to go back to.
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
return;
|
||||
}
|
||||
prev();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
[currentIndex, next, prev],
|
||||
);
|
||||
|
||||
// --- Swipe-down dismiss ---
|
||||
const translateY = useSharedValue(0);
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const exit = useCallback(() => {
|
||||
onExit();
|
||||
}, [onExit]);
|
||||
|
||||
const panDown = Gesture.Pan()
|
||||
.activeOffsetY(15)
|
||||
.failOffsetX([-30, 30])
|
||||
.failOffsetY(-20)
|
||||
.onUpdate((e) => {
|
||||
"worklet";
|
||||
translateY.value = Math.max(0, e.translationY);
|
||||
})
|
||||
.onEnd((e) => {
|
||||
"worklet";
|
||||
if (
|
||||
e.translationY > DISMISS_DISTANCE ||
|
||||
e.velocityY > DISMISS_VELOCITY
|
||||
) {
|
||||
translateY.value = withTiming(SCREEN_HEIGHT, { duration: 220 });
|
||||
runOnJS(exit)();
|
||||
} else {
|
||||
translateY.value = withSpring(0, {
|
||||
damping: 22,
|
||||
stiffness: 220,
|
||||
mass: 0.6,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Swipe-up opens the reaction sheet. Mirror the down pan's discipline —
|
||||
// fail on horizontal motion so it doesn't fight the tap-zones.
|
||||
const panUp = Gesture.Pan()
|
||||
.activeOffsetY(-15)
|
||||
.failOffsetX([-30, 30])
|
||||
.failOffsetY(20)
|
||||
.onEnd((e) => {
|
||||
"worklet";
|
||||
if (
|
||||
e.translationY < -REACTIONS_DISTANCE ||
|
||||
e.velocityY < -REACTIONS_VELOCITY
|
||||
) {
|
||||
runOnJS(openReactions)();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Tap (advance / regress) ---
|
||||
const tap = Gesture.Tap()
|
||||
.maxDuration(180)
|
||||
.maxDistance(15)
|
||||
.onEnd((e, success) => {
|
||||
"worklet";
|
||||
if (!success) return;
|
||||
const ratio = e.x / screenWidth;
|
||||
runOnJS(handleTap)(ratio);
|
||||
});
|
||||
|
||||
// --- Long-press (hold-to-pause) ---
|
||||
const longPress = Gesture.LongPress()
|
||||
.minDuration(180)
|
||||
.maxDistance(15)
|
||||
.onStart(() => {
|
||||
"worklet";
|
||||
runOnJS(setHoldActive)(true);
|
||||
})
|
||||
.onTouchesUp(() => {
|
||||
"worklet";
|
||||
runOnJS(setHoldActive)(false);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
"worklet";
|
||||
runOnJS(setHoldActive)(false);
|
||||
});
|
||||
|
||||
// Pan-down (dismiss), pan-up (reactions), and tap+longPress race against
|
||||
// each other. The first to clear its activeOffsetY wins; the others fail.
|
||||
const composed = Gesture.Race(
|
||||
panDown,
|
||||
panUp,
|
||||
Gesture.Simultaneous(tap, longPress),
|
||||
);
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
const opacity = interpolate(
|
||||
translateY.value,
|
||||
[0, SCREEN_HEIGHT * 0.5],
|
||||
[1, 0.4],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
const scale = interpolate(
|
||||
translateY.value,
|
||||
[0, SCREEN_HEIGHT],
|
||||
[1, 0.85],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
return {
|
||||
transform: [{ translateY: translateY.value }, { scale }],
|
||||
opacity,
|
||||
};
|
||||
});
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => {
|
||||
const opacity = interpolate(
|
||||
translateY.value,
|
||||
[0, SCREEN_HEIGHT * 0.5],
|
||||
[1, 0.6],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
return { opacity };
|
||||
});
|
||||
|
||||
// --- End-of-stream countdown ---
|
||||
const exitRemainingMs = useExitCountdown(status, paused, exit);
|
||||
|
||||
// Chrome reservations: top = safe-area + segmented bar (3) + gap (12) +
|
||||
// metadata row (~38) + breathing room (12). Bottom = safe-area + room for
|
||||
// pause / countdown pills + the compose dock that lands in this same step.
|
||||
const chromeTop = insets.top + 65;
|
||||
const chromeBottom = insets.bottom + 96;
|
||||
|
||||
// --- Render the active particle ---
|
||||
const renderParticle = (particle: Particle) => {
|
||||
if (isParticleDeleted(particle)) {
|
||||
return (
|
||||
<DeletedParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
networkId={networkId}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
/>
|
||||
);
|
||||
}
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
return (
|
||||
<TextParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
case "media":
|
||||
return (
|
||||
<MediaParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
onProgress={setProgress}
|
||||
contentFit={videoFit}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<FallbackParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
networkId={networkId}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Content guards ---
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center px-8">
|
||||
<StatusBar style="light" hidden />
|
||||
<Text className="text-white/70 text-base">
|
||||
No particles in this stream yet.
|
||||
</Text>
|
||||
<Pressable onPress={exit} className="mt-6 px-4 py-2">
|
||||
<Text className="text-white/60">Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[{ flex: 1 }, backdropStyle]} className="bg-black">
|
||||
<StatusBar style="light" hidden />
|
||||
<Animated.View style={[{ flex: 1 }, containerStyle]} className="bg-black">
|
||||
<GestureDetector gesture={composed}>
|
||||
<View className="flex-1">
|
||||
{/* Particle canvas — fills the whole screen, gesture-aware.
|
||||
StreamSafeAreaProvider tells particle views how much space the
|
||||
chrome occupies so scrollable content doesn't slip under. */}
|
||||
<StreamSafeAreaProvider top={chromeTop} bottom={chromeBottom}>
|
||||
<View className="flex-1">
|
||||
{/* While composing we fully unmount the particle so the
|
||||
underlying expo-video player releases the AVAudioSession.
|
||||
Otherwise it contends with expo-camera and crashes the
|
||||
app when video recording starts. */}
|
||||
{currentParticle && !composing
|
||||
? renderParticle(currentParticle)
|
||||
: null}
|
||||
</View>
|
||||
</StreamSafeAreaProvider>
|
||||
|
||||
{/* Top chrome: segmented bar + metadata. Painted over the canvas
|
||||
so the canvas can be edge-to-edge but content gets a safe-area
|
||||
gradient to read against. A real linear gradient (vs a flat
|
||||
bg-black/40 block) avoids the hard "bar" edge under the chrome. */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="absolute inset-x-0 top-0"
|
||||
style={{ height: insets.top + 120 }}
|
||||
>
|
||||
<Svg width="100%" height="100%">
|
||||
<Defs>
|
||||
<LinearGradient
|
||||
id="streamTopFade"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<Stop offset="0" stopColor="#000000" stopOpacity="0.55" />
|
||||
<Stop offset="1" stopColor="#000000" stopOpacity="0" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Rect width="100%" height="100%" fill="url(#streamTopFade)" />
|
||||
</Svg>
|
||||
</View>
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="absolute inset-x-0"
|
||||
style={{ top: insets.top + 8 }}
|
||||
>
|
||||
<View className="px-3">
|
||||
<PlaybackPageIndicator
|
||||
total={children.length}
|
||||
current={currentIndex}
|
||||
progress={progress}
|
||||
paused={paused}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bottom chrome: paused pill + exit countdown. Sit above the
|
||||
compose dock so the record button doesn't cover them. */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="absolute inset-x-0 bottom-0 items-center"
|
||||
style={{ paddingBottom: insets.bottom + COMPOSE_DOCK_HEIGHT }}
|
||||
>
|
||||
{paused ? (
|
||||
<View className="bg-white/15 rounded-full px-3 py-1">
|
||||
<Text className="text-white/90 text-xs font-medium">
|
||||
Paused
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{exitRemainingMs !== null ? (
|
||||
<View className="bg-white/15 rounded-full px-3 py-1 mt-2">
|
||||
<Text className="text-white/90 text-xs font-medium">
|
||||
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Top metadata + actions row — lifted OUTSIDE the GestureDetector so
|
||||
taps on the action cluster aren't claimed by the stream's tap
|
||||
gesture (which advances/regresses the playhead). The chain uses
|
||||
`box-none` so empty space still falls through to gestures below. */}
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
className="absolute inset-x-0"
|
||||
style={{ top: insets.top + 8 + 24 }}
|
||||
>
|
||||
<View className="px-4" pointerEvents="box-none">
|
||||
<View
|
||||
className="flex-row items-start gap-3"
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View className="flex-1" pointerEvents="none">
|
||||
<StreamMetadataHeader
|
||||
particle={currentParticle}
|
||||
network={network ?? null}
|
||||
/>
|
||||
</View>
|
||||
<StreamTopActions
|
||||
networkId={networkId}
|
||||
streamParticle={streamParticle}
|
||||
humans={network?.humans ?? []}
|
||||
videoFit={videoFit}
|
||||
onToggleVideoFit={() =>
|
||||
setVideoFit((v) => (v === "cover" ? "contain" : "cover"))
|
||||
}
|
||||
onOpenMembers={() => setMembersOpen(true)}
|
||||
onOpenActions={() => setActionsOpen(true)}
|
||||
showFitToggle={showFitToggle}
|
||||
/>
|
||||
</View>
|
||||
{composingUsers.length > 0 ? (
|
||||
<View className="mt-2" pointerEvents="none">
|
||||
<ComposingIndicator
|
||||
users={composingUsers}
|
||||
networkHumans={network?.humans}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Right-edge reaction stack — mirrors desktop's ReactionBar. Vertically
|
||||
centered on the canvas; outside the GestureDetector so each pill
|
||||
tap toggles cleanly without competing with the stream advance/back
|
||||
taps. Hidden during composing so the camera preview is unobstructed. */}
|
||||
{currentParticle &&
|
||||
!composing &&
|
||||
!isParticleDeleted(currentParticle) &&
|
||||
(currentParticle.type === "media" ||
|
||||
currentParticle.type === "text") ? (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
className="absolute right-3"
|
||||
style={{
|
||||
top: insets.top + 100,
|
||||
bottom: insets.bottom + COMPOSE_DOCK_HEIGHT + 40,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ReactionStack
|
||||
reactions={reactionsOnCurrent}
|
||||
currentHumanId={userId}
|
||||
humans={network?.humans}
|
||||
onToggle={handleToggleReaction}
|
||||
onOpenSheet={openReactions}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
||||
iOS's status-bar tap doesn't fight our gestures. */}
|
||||
<SafeAreaView edges={["top"]} pointerEvents="none" />
|
||||
|
||||
{/* Compose dock + recording overlays. Sits above the GestureDetector
|
||||
so its hold-FAB pan gesture isn't competed-with by the StreamView
|
||||
tap zones. */}
|
||||
<ComposeDock networkId={networkId} targetPath={path} />
|
||||
|
||||
{/* Reaction sheet — slides up over everything, suspends playback
|
||||
internally while open. */}
|
||||
<ReactionSheet
|
||||
open={reactionsOpen}
|
||||
onClose={() => setReactionsOpen(false)}
|
||||
reactions={reactionsOnCurrent}
|
||||
currentHumanId={userId}
|
||||
humans={network?.humans}
|
||||
onToggle={handleToggleReaction}
|
||||
/>
|
||||
|
||||
<StreamActionsSheet
|
||||
open={actionsOpen}
|
||||
onClose={() => setActionsOpen(false)}
|
||||
onSelect={(action) => void handleStreamAction(action)}
|
||||
streamStatus={streamParticle.status ?? "open"}
|
||||
isCreator={isCreator}
|
||||
canDeleteParticle={canDeleteCurrentParticle}
|
||||
/>
|
||||
|
||||
<StreamMembersSheet
|
||||
open={membersOpen}
|
||||
onClose={() => setMembersOpen(false)}
|
||||
networkId={networkId}
|
||||
streamParticle={streamParticle}
|
||||
isCreator={isCreator}
|
||||
/>
|
||||
|
||||
<RenameStreamSheet
|
||||
open={renameOpen}
|
||||
onClose={() => setRenameOpen(false)}
|
||||
networkId={networkId}
|
||||
streamId={streamParticle.id}
|
||||
currentName={streamParticle.properties.name}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { useLiveParticle } from "@/hooks/use-particle";
|
||||
import { StreamView } from "./StreamView";
|
||||
|
||||
export function StreamViewScreen({
|
||||
navigation,
|
||||
route,
|
||||
}: RootStackScreenProps<"StreamView">) {
|
||||
const { networkId, streamId } = route.params;
|
||||
const streamPath = particlePath(networkId, [streamId]);
|
||||
const { particle, isLoading, error } = useLiveParticle(streamPath);
|
||||
|
||||
if (isLoading && !particle) {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center">
|
||||
<StatusBar style="light" hidden />
|
||||
<ActivityIndicator color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !particle || particle.type !== "stream") {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center px-8">
|
||||
<StatusBar style="light" hidden />
|
||||
<Text className="text-white/70 text-center">
|
||||
{error
|
||||
? "Couldn't load this stream."
|
||||
: "This stream is no longer available."}
|
||||
</Text>
|
||||
<Pressable onPress={() => navigation.goBack()} className="mt-6 px-4 py-2">
|
||||
<Text className="text-white/60">Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamView
|
||||
streamParticle={particle}
|
||||
path={streamPath}
|
||||
onExit={() => navigation.goBack()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useStreamSafeArea } from "./stream-safe-area";
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: TextParticle;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
}
|
||||
|
||||
// Mirrors desktop's read-duration math (chars/min ≈ 1000, plus +2s per
|
||||
// link/attachment, clamped 3–15s). Mobile v1 has no attachments and we
|
||||
// don't extract link previews mid-render, so the formula collapses to
|
||||
// a length-only base.
|
||||
const CHARS_PER_MINUTE = 1000;
|
||||
const MIN_DURATION_S = 3;
|
||||
const MAX_DURATION_S = 15;
|
||||
const TICK_MS = 100;
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function computeReadDuration(text: string): number {
|
||||
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
||||
return Math.min(Math.max(base, MIN_DURATION_S), MAX_DURATION_S);
|
||||
}
|
||||
|
||||
function getImmersiveStyle(length: number) {
|
||||
if (length < 30)
|
||||
return { className: "text-5xl font-semibold leading-tight" };
|
||||
if (length < 70)
|
||||
return { className: "text-3xl font-semibold leading-snug" };
|
||||
return { className: "text-2xl font-normal leading-snug" };
|
||||
}
|
||||
|
||||
export function TextParticleView({
|
||||
particle,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}: TextParticleViewProps) {
|
||||
const content = particle.properties.content;
|
||||
const durationS = computeReadDuration(content);
|
||||
const elapsedRef = useRef(0);
|
||||
const safe = useStreamSafeArea();
|
||||
|
||||
// Reset when the particle changes.
|
||||
useEffect(() => {
|
||||
elapsedRef.current = 0;
|
||||
onProgress(0);
|
||||
}, [particle.id, onProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
elapsedRef.current += TICK_MS / 1000;
|
||||
const ratio = Math.min(elapsedRef.current / durationS, 1);
|
||||
onProgress(ratio);
|
||||
if (ratio >= 1) {
|
||||
clearInterval(interval);
|
||||
onEnded();
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, durationS, onEnded, onProgress, particle.id]);
|
||||
|
||||
// Immersive (short, plain): centered, large type — feels like a lock-screen note.
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT) {
|
||||
const style = getImmersiveStyle(content.length);
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center px-8"
|
||||
style={{
|
||||
paddingTop: safe.top + 16,
|
||||
paddingBottom: safe.bottom + 16,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={cn("text-white text-center max-w-xl", style.className)}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Long text: scrollable card so the reader can pace themselves; the
|
||||
// duration timer keeps ticking either way, which is intentional —
|
||||
// long messages should still auto-advance at the 15s cap. Padding is
|
||||
// pulled from the StreamSafeArea so the card never slips under chrome.
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center px-6"
|
||||
style={{
|
||||
paddingTop: safe.top + 16,
|
||||
paddingBottom: safe.bottom + 16,
|
||||
}}
|
||||
>
|
||||
<ScrollView
|
||||
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
|
||||
contentContainerClassName="px-5 py-5"
|
||||
showsVerticalScrollIndicator
|
||||
indicatorStyle="white"
|
||||
>
|
||||
<Text className="text-white text-lg leading-relaxed">{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useChannel } from "@/hooks/use-channel";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export type ComposingMode = "recording" | "typing" | "screen";
|
||||
|
||||
export interface ComposingUser {
|
||||
humanId: string;
|
||||
mode: ComposingMode;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
interface StreamPresenceContextValue {
|
||||
onlineHumanIds: Set<string>;
|
||||
composingUsers: ComposingUser[];
|
||||
startComposing: (mode: ComposingMode) => void;
|
||||
stopComposing: () => void;
|
||||
}
|
||||
|
||||
const COMPOSING_TIMEOUT_MS = 10_000;
|
||||
const COMPOSING_HEARTBEAT_MS = 5_000;
|
||||
const COMPOSING_CLEANUP_INTERVAL_MS = 2_000;
|
||||
|
||||
const StreamPresenceContext = createContext<StreamPresenceContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
interface StreamPresenceProviderProps {
|
||||
networkId: string;
|
||||
streamId: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StreamPresenceProvider({
|
||||
networkId,
|
||||
streamId,
|
||||
children,
|
||||
}: StreamPresenceProviderProps) {
|
||||
const channelId = `stream:${networkId}:${streamId}`;
|
||||
const { presence, messages, sendMessage } = useChannel(channelId);
|
||||
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const onlineHumanIds = useMemo(() => new Set(presence), [presence]);
|
||||
|
||||
// --- Composing state ---
|
||||
const [composingUsers, setComposingUsers] = useState<ComposingUser[]>([]);
|
||||
const composingMapRef = useRef(new Map<string, ComposingUser>());
|
||||
const processedCountRef = useRef(0);
|
||||
|
||||
// Process new messages incrementally — slicing the messages array means
|
||||
// we don't re-scan the whole history every render.
|
||||
useEffect(() => {
|
||||
if (messages.length <= processedCountRef.current) return;
|
||||
|
||||
const newMessages = messages.slice(processedCountRef.current);
|
||||
processedCountRef.current = messages.length;
|
||||
|
||||
let changed = false;
|
||||
const map = composingMapRef.current;
|
||||
|
||||
for (const msg of newMessages) {
|
||||
const payload = msg.payload as
|
||||
| { type: string; mode?: string }
|
||||
| undefined;
|
||||
if (!payload?.type) continue;
|
||||
if (msg.humanId === currentUserId) continue;
|
||||
|
||||
if (payload.type === "composing_start" && payload.mode) {
|
||||
map.set(msg.humanId, {
|
||||
humanId: msg.humanId,
|
||||
mode: payload.mode as ComposingMode,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
changed = true;
|
||||
} else if (payload.type === "composing_stop") {
|
||||
if (map.delete(msg.humanId)) changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setComposingUsers(Array.from(map.values()));
|
||||
}
|
||||
}, [messages, currentUserId]);
|
||||
|
||||
// Drop composing entries when a user leaves the channel — covers the
|
||||
// "they backgrounded the app without sending stop" case.
|
||||
useEffect(() => {
|
||||
const map = composingMapRef.current;
|
||||
const onlineSet = new Set(presence);
|
||||
let changed = false;
|
||||
|
||||
for (const humanId of map.keys()) {
|
||||
if (!onlineSet.has(humanId)) {
|
||||
map.delete(humanId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setComposingUsers(Array.from(map.values()));
|
||||
}
|
||||
}, [presence]);
|
||||
|
||||
// Sweep stale composing entries (last heartbeat > 10s ago).
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const map = composingMapRef.current;
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
|
||||
for (const [humanId, entry] of map) {
|
||||
if (now - entry.lastSeen > COMPOSING_TIMEOUT_MS) {
|
||||
map.delete(humanId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setComposingUsers(Array.from(map.values()));
|
||||
}
|
||||
}, COMPOSING_CLEANUP_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// --- Composing broadcast ---
|
||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const startComposing = useCallback(
|
||||
(mode: ComposingMode) => {
|
||||
sendMessage({ type: "composing_start", mode });
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
sendMessage({ type: "composing_start", mode });
|
||||
}, COMPOSING_HEARTBEAT_MS);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const stopComposing = useCallback(() => {
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = undefined;
|
||||
sendMessage({ type: "composing_stop" });
|
||||
}, [sendMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<StreamPresenceContextValue>(
|
||||
() => ({
|
||||
onlineHumanIds,
|
||||
composingUsers,
|
||||
startComposing,
|
||||
stopComposing,
|
||||
}),
|
||||
[onlineHumanIds, composingUsers, startComposing, stopComposing],
|
||||
);
|
||||
|
||||
return (
|
||||
<StreamPresenceContext.Provider value={value}>
|
||||
{children}
|
||||
</StreamPresenceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useStreamPresenceContext() {
|
||||
const ctx = useContext(StreamPresenceContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useStreamPresence must be used within a StreamPresenceProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useStreamPresence() {
|
||||
const { onlineHumanIds } = useStreamPresenceContext();
|
||||
return { onlineHumanIds };
|
||||
}
|
||||
|
||||
export function useStreamComposing() {
|
||||
const { composingUsers } = useStreamPresenceContext();
|
||||
return { composingUsers };
|
||||
}
|
||||
|
||||
export function useStreamComposingBroadcast() {
|
||||
const { startComposing, stopComposing } = useStreamPresenceContext();
|
||||
return { startComposing, stopComposing };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
interface StreamSafeArea {
|
||||
/** Pixels from the screen top reserved for the segmented bar + metadata. */
|
||||
top: number;
|
||||
/** Pixels from the screen bottom reserved for compose dock + pills. */
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
const Ctx = createContext<StreamSafeArea>({ top: 0, bottom: 0 });
|
||||
|
||||
/**
|
||||
* Lets particle views know how much vertical space the chrome reserves so
|
||||
* scrollable content (long text, future inboxes) doesn't slip under the
|
||||
* segmented bar / compose dock. Defaults to 0/0 so views work outside the
|
||||
* StreamView shell (e.g. in a preview).
|
||||
*/
|
||||
export function StreamSafeAreaProvider({
|
||||
top,
|
||||
bottom,
|
||||
children,
|
||||
}: StreamSafeArea & { children: ReactNode }) {
|
||||
return <Ctx.Provider value={{ top, bottom }}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useStreamSafeArea(): StreamSafeArea {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
|
||||
export const EXIT_DELAY_MS = 5000;
|
||||
export const EXIT_TICK_MS = 100;
|
||||
|
||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||
|
||||
/**
|
||||
* Returns the remaining ms when the stream has ended, or null otherwise.
|
||||
* Pauses while `paused` is true (compose, hold-to-pause, swipe-down…).
|
||||
*/
|
||||
export function useExitCountdown(
|
||||
status: PlaybackStatus,
|
||||
paused: boolean,
|
||||
onExit: () => void,
|
||||
): number | null {
|
||||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
||||
const handleExit = useEvent(onExit);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "ended") {
|
||||
setRemainingMs(EXIT_DELAY_MS);
|
||||
} else {
|
||||
setRemainingMs(null);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingMs === null || remainingMs <= 0 || paused) return;
|
||||
const interval = setInterval(() => {
|
||||
setRemainingMs((prev) => {
|
||||
if (prev === null) return null;
|
||||
const next = prev - EXIT_TICK_MS;
|
||||
return next <= 0 ? 0 : next;
|
||||
});
|
||||
}, EXIT_TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [remainingMs !== null && remainingMs > 0, paused, remainingMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingMs !== null && remainingMs <= 0) {
|
||||
handleExit();
|
||||
}
|
||||
}, [remainingMs, handleExit]);
|
||||
|
||||
return remainingMs;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { ChevronRight, Globe, Lock, X } from "lucide-react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { ComposeDock } from "@/features/compose/ComposeDock";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { generateRandomName } from "@/lib/random-name";
|
||||
import { createStreamWithFirstParticle } from "@/lib/upload";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import {
|
||||
buildNetworkVisibility,
|
||||
parseVisibleTo,
|
||||
} from "@/lib/stream-visibility";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { VisibilityPickerSheet } from "./VisibilityPickerSheet";
|
||||
|
||||
const STREAM_NAME_MAX = 60;
|
||||
|
||||
/**
|
||||
* Top-level stream creation. The user names the stream, picks visibility, and
|
||||
* composes the first particle on one screen — desktop's compose-overlay flow
|
||||
* collapsed into a touch-native single page.
|
||||
*/
|
||||
export function NewStreamScreen({
|
||||
route,
|
||||
navigation,
|
||||
}: RootStackScreenProps<"NewStream">) {
|
||||
const { networkId } = route.params;
|
||||
const network = useNetwork(networkId);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const suggestion = useMemo(() => generateRandomName(), []);
|
||||
const [name, setName] = useState("");
|
||||
const [visibleTo, setVisibleTo] = useState<string[]>(() =>
|
||||
buildNetworkVisibility(networkId),
|
||||
);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const effectiveName = name.trim() || suggestion;
|
||||
|
||||
const handleStreamCreated = (streamId: string) => {
|
||||
navigation.replace("StreamView", { networkId, streamId });
|
||||
};
|
||||
|
||||
const submitText = async (content: string) => {
|
||||
if (!userId) throw new Error("Not signed in.");
|
||||
try {
|
||||
const { streamId } = await createStreamWithFirstParticle({
|
||||
networkId,
|
||||
name: effectiveName,
|
||||
visibleTo,
|
||||
createdByHumanId: userId,
|
||||
firstParticle: { type: "text", content },
|
||||
});
|
||||
handleStreamCreated(streamId);
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const submitMedia = async ({
|
||||
fileUri,
|
||||
mimeType,
|
||||
durationMs,
|
||||
source,
|
||||
}: {
|
||||
fileUri: string;
|
||||
mimeType: string;
|
||||
durationMs: number;
|
||||
source: "camera" | "screen";
|
||||
}) => {
|
||||
if (!userId) throw new Error("Not signed in.");
|
||||
try {
|
||||
const { streamId } = await createStreamWithFirstParticle({
|
||||
networkId,
|
||||
name: effectiveName,
|
||||
visibleTo,
|
||||
createdByHumanId: userId,
|
||||
firstParticle: {
|
||||
type: "media",
|
||||
fileUri,
|
||||
mimeType,
|
||||
durationMs,
|
||||
source,
|
||||
},
|
||||
});
|
||||
handleStreamCreated(streamId);
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const placeholderPath = particlePath(networkId, []);
|
||||
|
||||
const visibility = parseVisibleTo(visibleTo, networkId);
|
||||
const visibleSummary =
|
||||
visibility.mode === "network"
|
||||
? `Everyone in ${network?.name ?? "this network"}`
|
||||
: `${visibility.humanIds.length} ${
|
||||
visibility.humanIds.length === 1 ? "person" : "people"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
<StatusBar style="light" />
|
||||
|
||||
<SafeAreaView edges={["top"]}>
|
||||
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Cancel"
|
||||
>
|
||||
<X color="white" size={22} strokeWidth={1.8} />
|
||||
</Pressable>
|
||||
<Text className="text-white text-base font-semibold">
|
||||
New stream
|
||||
</Text>
|
||||
<View style={{ width: 22 }} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
<View className="flex-1 px-6 pt-4">
|
||||
<Text className="text-white/60 text-xs uppercase tracking-wide mb-2">
|
||||
Name
|
||||
</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={(v) => setName(v.slice(0, STREAM_NAME_MAX))}
|
||||
placeholder={suggestion}
|
||||
placeholderTextColor="rgba(255,255,255,0.35)"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={STREAM_NAME_MAX}
|
||||
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg"
|
||||
/>
|
||||
|
||||
<Text className="text-white/60 text-xs uppercase tracking-wide mt-6 mb-2">
|
||||
Visible to
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setPickerOpen(true)}
|
||||
className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3"
|
||||
>
|
||||
{visibility.mode === "network" ? (
|
||||
<Globe color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
|
||||
) : (
|
||||
<Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
|
||||
)}
|
||||
<Text className="text-white text-base flex-1" numberOfLines={1}>
|
||||
{visibleSummary}
|
||||
</Text>
|
||||
<ChevronRight
|
||||
color="rgba(255,255,255,0.5)"
|
||||
size={18}
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<View className="mt-6 px-1">
|
||||
<Text className="text-white/50 text-sm">
|
||||
Hold the button below to record a voice or video message — that's
|
||||
the first particle in your new stream.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<ComposeDock
|
||||
networkId={networkId}
|
||||
targetPath={placeholderPath}
|
||||
silentPresence
|
||||
submitMedia={submitMedia}
|
||||
submitText={submitText}
|
||||
/>
|
||||
|
||||
<VisibilityPickerSheet
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
networkId={networkId}
|
||||
networkName={network?.name}
|
||||
humans={network?.humans ?? []}
|
||||
selfHumanId={userId}
|
||||
visibleTo={visibleTo}
|
||||
onChange={setVisibleTo}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { Particle, StreamProperties } from "@/api/types";
|
||||
import { isParticleDeleted } from "@/api/types";
|
||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||
import { useLiveLatestChild } from "@/hooks/use-particle";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
interface StreamCardProps {
|
||||
particle: Particle & { type: "stream"; properties: StreamProperties };
|
||||
networkId: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile counterpart of js/desktop/src/features/particles/stream-card.tsx —
|
||||
* same data wiring (subscribe to the latest child for unread + initials),
|
||||
* touch-tuned layout (single row, no preview thumbnail in v1).
|
||||
*/
|
||||
export const StreamCard = memo(function StreamCard({
|
||||
particle,
|
||||
networkId,
|
||||
onPress,
|
||||
}: StreamCardProps) {
|
||||
const streamPath = particlePath(networkId, [particle.id]);
|
||||
const { latestChild } = useLiveLatestChild(streamPath);
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
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]);
|
||||
|
||||
const previewLabel = useMemo(() => {
|
||||
if (!latestChild) return "No messages yet";
|
||||
if (isParticleDeleted(latestChild)) return "Message deleted";
|
||||
switch (latestChild.type) {
|
||||
case "media":
|
||||
return latestChild.properties.mime_type.startsWith("audio/")
|
||||
? "Voice message"
|
||||
: "Video message";
|
||||
case "text":
|
||||
return latestChild.properties.content;
|
||||
case "file":
|
||||
return latestChild.properties.filename;
|
||||
case "quest":
|
||||
return latestChild.properties.title;
|
||||
case "paper":
|
||||
return latestChild.properties.title;
|
||||
default:
|
||||
return "Update";
|
||||
}
|
||||
}, [latestChild]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
android_ripple={{ color: "rgba(0,0,0,0.05)" }}
|
||||
className={cn(
|
||||
"bg-card border-b px-3.5 py-3 flex-row items-center gap-3 active:bg-accent",
|
||||
isUnseen ? "border-primary" : "border-border",
|
||||
)}
|
||||
>
|
||||
<View
|
||||
className={cn(
|
||||
"h-10 w-10 items-center justify-center rounded-full",
|
||||
isUnseen ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
isUnseen ? "text-primary-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{initials}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={cn(
|
||||
"text-base",
|
||||
isUnseen
|
||||
? "text-foreground font-semibold"
|
||||
: "text-foreground font-medium",
|
||||
)}
|
||||
>
|
||||
{particle.properties.name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="text-muted-foreground mt-0.5 text-sm"
|
||||
>
|
||||
{previewLabel}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-end gap-1">
|
||||
{latestChild ? (
|
||||
<RelativeTimestamp
|
||||
date={latestChild.created_at}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
isUnseen ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{isUnseen ? (
|
||||
<View className="bg-primary h-2 w-2 rounded-full" />
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { StreamCard } from "./StreamCard";
|
||||
|
||||
export function StreamListScreen({
|
||||
route,
|
||||
navigation,
|
||||
}: RootStackScreenProps<"StreamList">) {
|
||||
const { networkId } = route.params;
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
const path = particlePath(networkId, []);
|
||||
|
||||
const { streams, isLoading, error } = useStreamParticles(path, {
|
||||
status: "open",
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||
<Header
|
||||
title={network?.name ?? "Streams"}
|
||||
onBack={() => navigation.goBack()}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<ErrorState message={toUserMessage(error)} />
|
||||
) : isLoading && streams.length === 0 ? (
|
||||
<LoadingState />
|
||||
) : streams.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<FlatList
|
||||
data={streams}
|
||||
keyExtractor={(s) => s.id}
|
||||
contentContainerClassName=""
|
||||
renderItem={({ item }) => (
|
||||
<StreamCard
|
||||
particle={item}
|
||||
networkId={networkId}
|
||||
onPress={() =>
|
||||
navigation.navigate("StreamView", {
|
||||
networkId,
|
||||
streamId: item.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ComposeFab
|
||||
onPress={() => navigation.navigate("NewStream", { networkId })}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
title,
|
||||
onBack,
|
||||
}: {
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
className="px-2 py-1"
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<Text className="text-foreground text-2xl">‹</Text>
|
||||
</Pressable>
|
||||
<Text
|
||||
className="flex-1 text-center text-foreground text-base font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<View className="w-8" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-foreground text-lg font-medium text-center">
|
||||
No streams yet.
|
||||
</Text>
|
||||
<Text className="text-muted-foreground mt-2 text-center">
|
||||
Tap the button below to start one — voice, video, or text.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-destructive text-center">{message}</Text>
|
||||
<Text className="text-muted-foreground mt-2 text-center text-xs">
|
||||
Streams reconnect automatically once the network is back.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposeFab({ onPress }: { onPress: () => void }) {
|
||||
return (
|
||||
<View className="absolute bottom-6 right-6">
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className="bg-primary h-14 w-14 items-center justify-center rounded-full active:opacity-80"
|
||||
accessibilityLabel="New stream"
|
||||
>
|
||||
<Text className="text-primary-foreground text-3xl leading-none">+</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Check, Globe, Lock, X } from "lucide-react-native";
|
||||
import type { Human } from "@/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import {
|
||||
buildCustomVisibility,
|
||||
buildNetworkVisibility,
|
||||
parseVisibleTo,
|
||||
} from "@/lib/stream-visibility";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
|
||||
interface VisibilityPickerSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
networkId: string;
|
||||
networkName: string | undefined;
|
||||
humans: Human[];
|
||||
selfHumanId: string | undefined;
|
||||
visibleTo: string[];
|
||||
onChange: (visibleTo: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch-native visibility picker. Mirrors desktop's stream-members-overlay
|
||||
* (network-wide vs. specific people) but as a bottom sheet that commits on
|
||||
* close — the parent's `visibleTo` only updates when the user taps Done.
|
||||
*/
|
||||
export function VisibilityPickerSheet({
|
||||
open,
|
||||
onClose,
|
||||
networkId,
|
||||
networkName,
|
||||
humans,
|
||||
selfHumanId,
|
||||
visibleTo,
|
||||
onChange,
|
||||
}: VisibilityPickerSheetProps) {
|
||||
const initial = useMemo(
|
||||
() => parseVisibleTo(visibleTo, networkId),
|
||||
[visibleTo, networkId],
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<"network" | "custom">(initial.mode);
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
() => new Set(initial.mode === "custom" ? initial.humanIds : []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode(initial.mode);
|
||||
setSelected(
|
||||
new Set(initial.mode === "custom" ? initial.humanIds : []),
|
||||
);
|
||||
}, [open, initial]);
|
||||
|
||||
const others = humans.filter((h) => h.id !== selfHumanId);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (mode === "network") {
|
||||
onChange(buildNetworkVisibility(networkId));
|
||||
} else {
|
||||
const ids = selfHumanId
|
||||
? [selfHumanId, ...Array.from(selected)]
|
||||
: Array.from(selected);
|
||||
onChange(buildCustomVisibility(ids));
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const customCount = selected.size + (selfHumanId ? 1 : 0);
|
||||
const canCommit = mode === "network" || customCount >= 2;
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose} maxHeight="80%">
|
||||
<View className="flex-row items-center justify-between px-5 pb-3">
|
||||
<Pressable onPress={onClose} hitSlop={12}>
|
||||
<X color="rgba(255,255,255,0.7)" size={22} />
|
||||
</Pressable>
|
||||
<Text className="text-white text-base font-semibold">Visible to</Text>
|
||||
<Pressable onPress={commit} disabled={!canCommit} hitSlop={12}>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-semibold",
|
||||
canCommit ? "text-white" : "text-white/30",
|
||||
)}
|
||||
>
|
||||
Done
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="px-5 pb-3">
|
||||
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
||||
<ModePill
|
||||
active={mode === "network"}
|
||||
icon={<Globe color="white" size={14} />}
|
||||
label="Everyone"
|
||||
onPress={() => setMode("network")}
|
||||
/>
|
||||
<ModePill
|
||||
active={mode === "custom"}
|
||||
icon={<Lock color="white" size={14} />}
|
||||
label="Specific people"
|
||||
onPress={() => setMode("custom")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{mode === "network" ? (
|
||||
<View className="px-5 pb-6">
|
||||
<Text className="text-white/60 text-sm">
|
||||
Everyone in {networkName ?? "this network"} can see this stream.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView contentContainerClassName="px-2 pb-4">
|
||||
{others.length === 0 ? (
|
||||
<Text className="text-white/50 text-sm px-3 py-4">
|
||||
You're the only member of this network. Invite people on desktop,
|
||||
then come back to choose specific viewers.
|
||||
</Text>
|
||||
) : (
|
||||
others.map((human) => {
|
||||
const display = resolveHumanDisplay(human.id, humans);
|
||||
const isSelected = selected.has(human.id);
|
||||
return (
|
||||
<Pressable
|
||||
key={human.id}
|
||||
onPress={() => toggle(human.id)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-3 px-3 py-2.5 rounded-lg",
|
||||
isSelected ? "bg-white/10" : "active:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
humanId={human.id}
|
||||
humans={humans}
|
||||
size="sm"
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-white text-sm font-medium"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-white/40 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.email}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={cn(
|
||||
"h-6 w-6 items-center justify-center rounded-full border",
|
||||
isSelected
|
||||
? "bg-white border-white"
|
||||
: "border-white/30",
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<Check color="black" size={14} strokeWidth={3} />
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function ModePill({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={cn(
|
||||
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2",
|
||||
active ? "bg-white/15" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<Text
|
||||
className={cn(
|
||||
"text-xs",
|
||||
active ? "text-white font-semibold" : "text-white/60",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { initializeApp } from "firebase/app";
|
||||
import {
|
||||
initializeAuth,
|
||||
// `getReactNativePersistence` is documented Firebase RN setup but Firebase
|
||||
// intentionally omits it from `firebase/auth`'s public type bundle (it
|
||||
// would pollute web autocomplete). The runtime export exists on every
|
||||
// platform; this is the workaround the Firebase docs themselves use.
|
||||
// @ts-expect-error — RN-only symbol missing from public Firebase types.
|
||||
getReactNativePersistence,
|
||||
} from "firebase/auth";
|
||||
import { initializeFirestore } from "firebase/firestore";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { appConfig } from "@/config/env";
|
||||
|
||||
export const firebaseApp = initializeApp(appConfig.firebase);
|
||||
|
||||
// AsyncStorage persists the Firebase auth token across cold starts. Without
|
||||
// it, the user would have to re-sign-in to Firestore every launch even though
|
||||
// the Orion bearer token is in SecureStore.
|
||||
export const firebaseAuth = initializeAuth(firebaseApp, {
|
||||
persistence: getReactNativePersistence(AsyncStorage),
|
||||
});
|
||||
|
||||
// Firestore's default WebChannel transport often fails on cellular networks
|
||||
// and behind aggressive proxies on iOS. Long-polling is the documented
|
||||
// remedy for React Native and is also what the Firebase team recommends
|
||||
// for mobile apps using the JS SDK.
|
||||
export const firestoreDb = initializeFirestore(firebaseApp, {
|
||||
experimentalAutoDetectLongPolling: true,
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { usePusherClient } from "@/lib/pusher-provider";
|
||||
import type { ChannelMessage } from "@/lib/pusher-client";
|
||||
|
||||
interface UseChannelResult {
|
||||
/** Current set of humanIds present in the channel */
|
||||
presence: string[];
|
||||
/** Messages received on this channel (since the hook mounted) */
|
||||
messages: ChannelMessage[];
|
||||
/** Send a message to the channel */
|
||||
sendMessage: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a pusher channel. Manages presence tracking and message delivery.
|
||||
* Subscribes on mount, unsubscribes on unmount.
|
||||
*
|
||||
* @param channelId - The channel to subscribe to, or null to skip.
|
||||
*/
|
||||
export function useChannel(channelId: string | null): UseChannelResult {
|
||||
const client = usePusherClient();
|
||||
const [presence, setPresence] = useState<string[]>([]);
|
||||
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !channelId) {
|
||||
setPresence([]);
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
client.subscribe(channelId);
|
||||
|
||||
const onSubscribed = (msg: { presence?: string[] }) => {
|
||||
setPresence(msg.presence ?? []);
|
||||
};
|
||||
|
||||
const onJoin = (msg: { humanId?: string }) => {
|
||||
if (msg.humanId) {
|
||||
setPresence((prev) =>
|
||||
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onLeave = (msg: { humanId?: string }) => {
|
||||
if (msg.humanId) {
|
||||
setPresence((prev) => prev.filter((id) => id !== msg.humanId));
|
||||
}
|
||||
};
|
||||
|
||||
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
|
||||
if (msg.humanId) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ humanId: msg.humanId!, payload: msg.payload },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
client.on(channelId, "subscribed", onSubscribed);
|
||||
client.on(channelId, "join", onJoin);
|
||||
client.on(channelId, "leave", onLeave);
|
||||
client.on(channelId, "message", onMessage);
|
||||
|
||||
return () => {
|
||||
client.off(channelId, "subscribed", onSubscribed);
|
||||
client.off(channelId, "join", onJoin);
|
||||
client.off(channelId, "leave", onLeave);
|
||||
client.off(channelId, "message", onMessage);
|
||||
client.unsubscribe(channelId);
|
||||
};
|
||||
}, [client, channelId]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (client && channelId) {
|
||||
client.sendMessage(channelId, payload);
|
||||
}
|
||||
},
|
||||
[client, channelId],
|
||||
);
|
||||
|
||||
return { presence, messages, sendMessage };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
|
||||
// Polyfill for React's `useEffectEvent` (canary). The returned function has a
|
||||
// stable identity but always sees the latest closure — exactly what
|
||||
// `useEffectEvent` provides. Stable enough that we use it everywhere we'd
|
||||
// otherwise reach for a ref + .current dance inside an effect.
|
||||
//
|
||||
// Replace with `useEffectEvent` once it ships in stable React. Call sites
|
||||
// don't need to change.
|
||||
export function useEvent<TArgs extends unknown[], TReturn>(
|
||||
fn: (...args: TArgs) => TReturn,
|
||||
): (...args: TArgs) => TReturn {
|
||||
const ref = useRef(fn);
|
||||
useLayoutEffect(() => {
|
||||
ref.current = fn;
|
||||
});
|
||||
return useCallback((...args: TArgs) => ref.current(...args), []);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export function useNetworks() {
|
||||
return useQuery({
|
||||
queryKey: ["networks"],
|
||||
queryFn: () => apiClient.listNetworks(),
|
||||
meta: { toastOnError: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function useNetwork(networkId: string) {
|
||||
const { data: networks } = useNetworks();
|
||||
return networks?.find((n) => n.id === networkId) ?? null;
|
||||
}
|
||||
|
||||
export function useIsNetworkAdmin(networkId: string): boolean {
|
||||
const network = useNetwork(networkId);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
if (!network || !userId) return false;
|
||||
return network.admin_human.id === userId;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { QueryFieldFilterConstraint } from "firebase/firestore";
|
||||
import {
|
||||
subscribeToParticle,
|
||||
subscribeToParticleChildren,
|
||||
subscribeToLatestChild,
|
||||
getParticle,
|
||||
getParticleChildren,
|
||||
} from "@/lib/firestore-particles";
|
||||
import type { Particle } from "@/api/types";
|
||||
import {
|
||||
type ParticlePath,
|
||||
toFirestoreDocPath,
|
||||
toFirestoreChildrenPath,
|
||||
} from "@/lib/particle-path";
|
||||
import { logError } from "@/lib/errors";
|
||||
|
||||
interface UseLiveParticleResult {
|
||||
particle: Particle | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
|
||||
const [particle, setParticle] = useState<Particle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setParticle(null);
|
||||
|
||||
const docPath = toFirestoreDocPath(path);
|
||||
const unsubscribe = subscribeToParticle(
|
||||
docPath,
|
||||
(data) => {
|
||||
setParticle(data);
|
||||
setIsLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
setError(err);
|
||||
setIsLoading(false);
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [path]);
|
||||
|
||||
return { particle, isLoading, error };
|
||||
}
|
||||
|
||||
interface UseLiveParticleChildrenResult {
|
||||
children: Particle[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface UseLiveParticleChildrenParams {
|
||||
orderByField?: string;
|
||||
orderDirection?: "asc" | "desc";
|
||||
visibilityScopes?: string[];
|
||||
onAdded?: (child: Particle) => void;
|
||||
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
||||
whereFilter?: QueryFieldFilterConstraint;
|
||||
/** Optional cap on results. Changes trigger a re-subscription. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function useLiveParticleChildren(
|
||||
path: ParticlePath | undefined,
|
||||
{
|
||||
orderByField = "created_at",
|
||||
orderDirection = "desc",
|
||||
visibilityScopes,
|
||||
onAdded,
|
||||
onRemoved,
|
||||
whereFilter,
|
||||
limit,
|
||||
}: UseLiveParticleChildrenParams = {},
|
||||
): UseLiveParticleChildrenResult {
|
||||
const [children, setChildren] = useState<Particle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) {
|
||||
setChildren([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setChildren([]);
|
||||
|
||||
const collectionPath = toFirestoreChildrenPath(path);
|
||||
|
||||
const unsubscribe = subscribeToParticleChildren(collectionPath, {
|
||||
onData: (data) => {
|
||||
setChildren(data);
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
logError(err, { scope: "firestore.particle-children", path });
|
||||
setError(err);
|
||||
setIsLoading(false);
|
||||
},
|
||||
visibilityScopes,
|
||||
orderByField,
|
||||
orderDirection,
|
||||
onAdded,
|
||||
onRemoved,
|
||||
whereFilter,
|
||||
limit,
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
// The hook intentionally keys only on path/whereFilter/limit — desktop
|
||||
// does the same. Visibility scope changes are absorbed by the active
|
||||
// listener; reordering causes a re-subscription.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path, whereFilter, limit]);
|
||||
|
||||
return { children, isLoading, error };
|
||||
}
|
||||
|
||||
interface UseLiveLatestChildResult {
|
||||
latestChild: Particle | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useLiveLatestChild(
|
||||
path: ParticlePath,
|
||||
): UseLiveLatestChildResult {
|
||||
const [latestChild, setLatestChild] = useState<Particle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setLatestChild(null);
|
||||
|
||||
const unsubscribe = subscribeToLatestChild(
|
||||
toFirestoreChildrenPath(path),
|
||||
(data) => {
|
||||
setLatestChild(data);
|
||||
setIsLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
logError(err, { scope: "firestore.latest-child", path });
|
||||
setIsLoading(false);
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [path]);
|
||||
|
||||
return { latestChild, isLoading };
|
||||
}
|
||||
|
||||
export function useParticle(path?: ParticlePath) {
|
||||
return useQuery({
|
||||
queryKey: ["particle", path],
|
||||
queryFn: async () => {
|
||||
if (!path) return null;
|
||||
const docPath = toFirestoreDocPath(path);
|
||||
const particle = await getParticle(docPath);
|
||||
return particle;
|
||||
},
|
||||
enabled: !!path,
|
||||
});
|
||||
}
|
||||
|
||||
export function useParticleChildren(path?: ParticlePath) {
|
||||
return useQuery({
|
||||
queryKey: ["particle-children", path],
|
||||
queryFn: async () => {
|
||||
if (!path) return [];
|
||||
const collectionPath = toFirestoreChildrenPath(path);
|
||||
return getParticleChildren(collectionPath);
|
||||
},
|
||||
enabled: !!path,
|
||||
staleTime: 1000 * 60 * 5, // 5 min — attachments don't change
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, 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 CLOSED_INITIAL_PAGE_SIZE = 50;
|
||||
const CLOSED_PAGE_INCREMENT = 50;
|
||||
|
||||
// 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. Open streams are loaded in full (bounded
|
||||
* by active work — full realtime coverage is needed for autoplay/huddles).
|
||||
* Closed streams are paginated via `loadMore`.
|
||||
*/
|
||||
status: "open" | "closed";
|
||||
}
|
||||
|
||||
interface UseStreamParticlesResult {
|
||||
streams: StreamParticle[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
networkId: string;
|
||||
/** True when more closed streams may exist beyond the current window. */
|
||||
canLoadMore: boolean;
|
||||
/** Extend the pagination window. No-op on the open tab. */
|
||||
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 [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
|
||||
|
||||
// Every time the user switches back to the closed tab, start with a fresh
|
||||
// window. Avoids an ever-growing subscription across a long session.
|
||||
useEffect(() => {
|
||||
if (status === "closed") {
|
||||
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const whereFilter: QueryFieldFilterConstraint =
|
||||
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
|
||||
const limit = status === "closed" ? closedLimit : undefined;
|
||||
|
||||
const { children, isLoading, error } = 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 = status === "closed" && streams.length >= closedLimit;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (status !== "closed") return;
|
||||
setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT);
|
||||
}, [status]);
|
||||
|
||||
return { streams, isLoading, error, networkId, canLoadMore, loadMore };
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
||||
import { logError } from "@/lib/errors";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
|
||||
// --- Playback reducer (ID-based) ---
|
||||
|
||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||
|
||||
interface PlaybackState {
|
||||
currentParticleId: string | null;
|
||||
status: PlaybackStatus;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
type PlaybackAction =
|
||||
| { type: "INIT"; particleId: string }
|
||||
| { type: "SET_PARTICLE"; particleId: string }
|
||||
| { type: "END" }
|
||||
| { type: "PARTICLE_ADDED"; particleId: string }
|
||||
| {
|
||||
type: "PARTICLE_REMOVED";
|
||||
removedParticleId: string;
|
||||
fallbackParticleId: string | null;
|
||||
};
|
||||
|
||||
const initialState: PlaybackState = {
|
||||
currentParticleId: null,
|
||||
status: "idle",
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
function playbackReducer(
|
||||
state: PlaybackState,
|
||||
action: PlaybackAction,
|
||||
): PlaybackState {
|
||||
switch (action.type) {
|
||||
case "INIT":
|
||||
return {
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
initialized: true,
|
||||
};
|
||||
case "SET_PARTICLE":
|
||||
return {
|
||||
...state,
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
};
|
||||
case "END":
|
||||
return { ...state, status: "ended" };
|
||||
case "PARTICLE_ADDED":
|
||||
if (state.status === "ended") {
|
||||
return {
|
||||
...state,
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
};
|
||||
}
|
||||
return state;
|
||||
case "PARTICLE_REMOVED":
|
||||
if (action.removedParticleId !== state.currentParticleId) return state;
|
||||
if (action.fallbackParticleId) {
|
||||
return {
|
||||
...state,
|
||||
currentParticleId: action.fallbackParticleId,
|
||||
status: "playing",
|
||||
};
|
||||
}
|
||||
return { ...state, currentParticleId: null, status: "idle" };
|
||||
}
|
||||
}
|
||||
|
||||
const INIT_FALLBACK_TIMEOUT_MS = 5000;
|
||||
|
||||
interface UseStreamPlaybackResult {
|
||||
children: Particle[];
|
||||
currentParticle: Particle | null;
|
||||
currentIndex: number;
|
||||
status: PlaybackStatus;
|
||||
initialized: boolean;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
goTo: (index: number) => void;
|
||||
goToParticle: (particleId: string) => void;
|
||||
}
|
||||
|
||||
export function useStreamPlayback(
|
||||
streamParticle: Particle & { type: "stream" },
|
||||
path: ParticlePath,
|
||||
): UseStreamPlaybackResult {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
||||
// Track which stream we initialized for, so navigating to a sibling resets cleanly.
|
||||
const initializedForRef = useRef<string | null>(null);
|
||||
|
||||
const onParticleAdded = useCallback((particle: Particle) => {
|
||||
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
|
||||
}, []);
|
||||
|
||||
const onParticleRemoved = useEvent(
|
||||
(removed: Particle, updatedChildren: Particle[]) => {
|
||||
const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1);
|
||||
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
|
||||
dispatch({
|
||||
type: "PARTICLE_REMOVED",
|
||||
removedParticleId: removed.id,
|
||||
fallbackParticleId: fallback?.id ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { children } = useLiveParticleChildren(path, {
|
||||
orderByField: "created_at",
|
||||
orderDirection: "asc",
|
||||
onAdded: onParticleAdded,
|
||||
onRemoved: onParticleRemoved,
|
||||
});
|
||||
|
||||
// Derive current index and particle from ID
|
||||
const currentIndex = useMemo(() => {
|
||||
if (!state.currentParticleId) return -1;
|
||||
return children.findIndex((c) => c.id === state.currentParticleId);
|
||||
}, [children, state.currentParticleId]);
|
||||
|
||||
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
|
||||
|
||||
const initFallback = useEvent(() => {
|
||||
if (state.initialized || children.length === 0) return;
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: children[0].id });
|
||||
});
|
||||
|
||||
// --- Init logic: runs on every children change until initialized ---
|
||||
useEffect(() => {
|
||||
if (
|
||||
initializedForRef.current !== null &&
|
||||
initializedForRef.current !== streamParticle.id
|
||||
) {
|
||||
initializedForRef.current = null;
|
||||
}
|
||||
|
||||
if (state.initialized && initializedForRef.current === streamParticle.id)
|
||||
return;
|
||||
|
||||
if (children.length === 0) return;
|
||||
|
||||
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
|
||||
|
||||
if (!playbackPosition) {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: children[0].id });
|
||||
return;
|
||||
}
|
||||
|
||||
const found = children.find(
|
||||
(c) => c.created_at.getTime() > playbackPosition.getTime(),
|
||||
);
|
||||
|
||||
if (found) {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: found.id });
|
||||
return;
|
||||
} else {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({
|
||||
type: "INIT",
|
||||
particleId: children[children.length - 1].id,
|
||||
});
|
||||
}
|
||||
|
||||
const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [
|
||||
children,
|
||||
streamParticle.id,
|
||||
streamParticle.playback_markers,
|
||||
userId,
|
||||
state.initialized,
|
||||
initFallback,
|
||||
]);
|
||||
|
||||
// --- Persist playback marker (only advance forward, never backwards) ---
|
||||
const lastPersistedMarkerRef = useRef<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !state.initialized || !currentParticle) return;
|
||||
|
||||
const currentTime = currentParticle.created_at;
|
||||
const existingMarker =
|
||||
lastPersistedMarkerRef.current ??
|
||||
streamParticle.playback_markers?.[userId];
|
||||
|
||||
if (existingMarker && currentTime.getTime() <= existingMarker.getTime())
|
||||
return;
|
||||
|
||||
lastPersistedMarkerRef.current = currentTime;
|
||||
const streamDocPath = toFirestoreDocPath(path);
|
||||
updateStreamPlaybackMarker(streamDocPath, userId, currentTime).catch(
|
||||
(err) => logError(err, { scope: "playback.marker", path }),
|
||||
);
|
||||
// streamParticle.playback_markers is read at effect time; not in deps to
|
||||
// avoid double-writes when the snapshot we just persisted echoes back.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentParticle?.id, state.initialized, userId, path]);
|
||||
|
||||
// --- Navigation callbacks ---
|
||||
const next = useCallback(() => {
|
||||
if (currentIndex === -1) return;
|
||||
if (currentIndex < children.length - 1) {
|
||||
dispatch({
|
||||
type: "SET_PARTICLE",
|
||||
particleId: children[currentIndex + 1].id,
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: "END" });
|
||||
}
|
||||
}, [children, currentIndex]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
if (currentIndex <= 0) return;
|
||||
dispatch({
|
||||
type: "SET_PARTICLE",
|
||||
particleId: children[currentIndex - 1].id,
|
||||
});
|
||||
}, [children, currentIndex]);
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0 && index < children.length) {
|
||||
dispatch({ type: "SET_PARTICLE", particleId: children[index].id });
|
||||
}
|
||||
},
|
||||
[children],
|
||||
);
|
||||
|
||||
// If the particle isn't in `children` yet (e.g. just-created), the live
|
||||
// query will resolve it shortly and the derived index/particle will catch up.
|
||||
const goToParticle = useCallback((particleId: string) => {
|
||||
dispatch({ type: "SET_PARTICLE", particleId });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
children,
|
||||
currentParticle,
|
||||
currentIndex,
|
||||
status: state.status,
|
||||
initialized: state.initialized,
|
||||
next,
|
||||
prev,
|
||||
goTo,
|
||||
goToParticle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useId } from "react";
|
||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
||||
|
||||
/**
|
||||
* Suspend stream playback while `active` is true. The hook owns its own
|
||||
* registration id; multiple instances compose. `label` is for devtools only.
|
||||
*/
|
||||
export function useSuspendPlayback(active: boolean, label: string) {
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const { add, remove } = usePlaybackPauseStore.getState();
|
||||
add(id, label);
|
||||
return () => remove(id);
|
||||
}, [active, id, label]);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
import { appEnv } from "@/config/env";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a free-plan network attempts to create a non-container particle
|
||||
* after hitting its daily message limit. Compose UI also disables triggers
|
||||
* proactively via `useNetworkUsage` — this throw is a last-line defense.
|
||||
*/
|
||||
export class QuotaExceededError extends Error {
|
||||
constructor(public readonly networkId: string) {
|
||||
super("Daily message limit reached");
|
||||
this.name = "QuotaExceededError";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMessage(message: string): string {
|
||||
return message.replace(/^Error:\s*/, "").trim();
|
||||
}
|
||||
|
||||
export function toUserMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 401) return "Please sign in again.";
|
||||
if (err.status === 403) return "You don't have permission to do that.";
|
||||
if (err.status === 404) return "Not found.";
|
||||
if (err.status === 408 || err.status === 429) {
|
||||
return "Please try again in a moment.";
|
||||
}
|
||||
if (err.status >= 500) {
|
||||
return "Something went wrong on our end. Please try again.";
|
||||
}
|
||||
return normalizeMessage(err.message) || "Request failed.";
|
||||
}
|
||||
|
||||
if (err instanceof z.ZodError) {
|
||||
return "Received unexpected data from the server.";
|
||||
}
|
||||
|
||||
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
|
||||
return "Network error. Check your connection.";
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
return normalizeMessage(err.message) || "Something went wrong.";
|
||||
}
|
||||
|
||||
return "Something went wrong.";
|
||||
}
|
||||
|
||||
type ErrorContext = Record<string, unknown>;
|
||||
type ErrorSink = (err: unknown, context?: ErrorContext) => void;
|
||||
|
||||
// Sentry (or any observability backend) installs itself via `installErrorSinks`
|
||||
// from App.tsx. Until then, logError is a dev-only console call and reportError
|
||||
// always prints — no call site needs to know.
|
||||
let captureSink: ErrorSink | null = null;
|
||||
let breadcrumbSink: ErrorSink | null = null;
|
||||
|
||||
export function installErrorSinks(sinks: {
|
||||
capture: ErrorSink;
|
||||
breadcrumb: ErrorSink;
|
||||
}): void {
|
||||
captureSink = sinks.capture;
|
||||
breadcrumbSink = sinks.breadcrumb;
|
||||
}
|
||||
|
||||
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
|
||||
export function logError(err: unknown, context?: ErrorContext): void {
|
||||
if (appEnv === "dev") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[error]", err, context ?? {});
|
||||
}
|
||||
breadcrumbSink?.(err, context);
|
||||
}
|
||||
|
||||
/** Unexpected failures the user may not see. Always captured. */
|
||||
export function reportError(err: unknown, context?: ErrorContext): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[error]", err, context ?? {});
|
||||
captureSink?.(err, context);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import {
|
||||
collection,
|
||||
doc,
|
||||
onSnapshot,
|
||||
addDoc,
|
||||
getDoc,
|
||||
getDocs,
|
||||
updateDoc,
|
||||
query,
|
||||
orderBy,
|
||||
limit,
|
||||
serverTimestamp,
|
||||
where,
|
||||
Timestamp,
|
||||
arrayUnion,
|
||||
arrayRemove,
|
||||
type DocumentData,
|
||||
type FirestoreDataConverter,
|
||||
type QueryDocumentSnapshot,
|
||||
type SnapshotOptions,
|
||||
type Unsubscribe,
|
||||
type QueryFieldFilterConstraint,
|
||||
} from "firebase/firestore";
|
||||
import { firestoreDb } from "@/firebase";
|
||||
import { isContainerType, ParticleSchema } from "@/api/types";
|
||||
import type {
|
||||
Particle,
|
||||
ParticleType,
|
||||
ParticlePropertiesMap,
|
||||
Reactions,
|
||||
} from "@/api/types";
|
||||
|
||||
// --- Converter ---
|
||||
|
||||
const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
toFirestore(particle: Particle): DocumentData {
|
||||
const { id: _id, created_at, updated_at, ...rest } = particle;
|
||||
const deletedAt =
|
||||
"deleted_at" in particle ? particle.deleted_at : undefined;
|
||||
return {
|
||||
...rest,
|
||||
created_at: Timestamp.fromDate(created_at),
|
||||
...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }),
|
||||
...(deletedAt && { deleted_at: Timestamp.fromDate(deletedAt) }),
|
||||
};
|
||||
},
|
||||
fromFirestore(
|
||||
snap: QueryDocumentSnapshot,
|
||||
options?: SnapshotOptions,
|
||||
): Particle {
|
||||
const raw = snap.data(options);
|
||||
if (typeof raw.type !== "string") {
|
||||
throw new Error(`Invalid particle type: ${raw.type}`);
|
||||
}
|
||||
const type = raw.type as ParticleType;
|
||||
switch (type) {
|
||||
case "stream":
|
||||
return ParticleSchema.parse({
|
||||
id: snap.id,
|
||||
type: raw.type,
|
||||
properties: raw.properties,
|
||||
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,
|
||||
visible_to: raw.visible_to,
|
||||
playback_markers: raw.playback_markers
|
||||
? Object.fromEntries(
|
||||
Object.entries(raw.playback_markers).map(([key, value]) => [
|
||||
key,
|
||||
(value as Timestamp).toDate(),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
last_child_created_at: raw.last_child_created_at
|
||||
? (raw.last_child_created_at as Timestamp).toDate()
|
||||
: undefined,
|
||||
huddle_active_participants:
|
||||
raw.huddle_active_participants ?? undefined,
|
||||
status: raw.status ?? undefined,
|
||||
});
|
||||
case "folder":
|
||||
return ParticleSchema.parse({
|
||||
id: snap.id,
|
||||
type: raw.type,
|
||||
properties: raw.properties,
|
||||
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,
|
||||
visible_to: raw.visible_to,
|
||||
});
|
||||
case "media":
|
||||
case "file":
|
||||
case "text":
|
||||
case "quest":
|
||||
case "paper": {
|
||||
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
||||
// particles carry `properties.edited_at`, so coerce it if present.
|
||||
const properties =
|
||||
type === "text" && raw.properties?.edited_at
|
||||
? {
|
||||
...raw.properties,
|
||||
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
|
||||
}
|
||||
: raw.properties;
|
||||
return ParticleSchema.parse({
|
||||
id: snap.id,
|
||||
type: raw.type,
|
||||
properties,
|
||||
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,
|
||||
reactions: raw.reactions ?? undefined,
|
||||
deleted_at: raw.deleted_at
|
||||
? (raw.deleted_at as Timestamp).toDate()
|
||||
: undefined,
|
||||
deleted_by_human_id: raw.deleted_by_human_id ?? undefined,
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown particle type: ${type}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// --- Typed reference helpers ---
|
||||
|
||||
function typedDoc(path: string) {
|
||||
return doc(firestoreDb, path).withConverter(particleConverter);
|
||||
}
|
||||
|
||||
function typedCollection(path: string) {
|
||||
return collection(firestoreDb, path).withConverter(particleConverter);
|
||||
}
|
||||
|
||||
// --- Exported operations ---
|
||||
|
||||
export function subscribeToParticle(
|
||||
docPath: string,
|
||||
onData: (particle: Particle | null) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Unsubscribe {
|
||||
return onSnapshot(
|
||||
typedDoc(docPath),
|
||||
(snap) => {
|
||||
onData(snap.exists() ? snap.data() : null);
|
||||
},
|
||||
onError,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getParticle(docPath: string): Promise<Particle | null> {
|
||||
const docSnap = await getDoc(typedDoc(docPath));
|
||||
if (!docSnap.exists()) {
|
||||
return null;
|
||||
}
|
||||
return docSnap.data();
|
||||
}
|
||||
|
||||
export interface GetParticleChildrenOptions {
|
||||
orderByField: string;
|
||||
orderDirection: "asc" | "desc";
|
||||
}
|
||||
|
||||
export async function getParticleChildren(
|
||||
collectionPath: string,
|
||||
{
|
||||
orderByField = "created_at",
|
||||
orderDirection = "asc",
|
||||
}: GetParticleChildrenOptions = {
|
||||
orderByField: "created_at",
|
||||
orderDirection: "asc",
|
||||
},
|
||||
): Promise<Particle[]> {
|
||||
const q = query(
|
||||
typedCollection(collectionPath),
|
||||
orderBy(orderByField, orderDirection),
|
||||
);
|
||||
const snap = await getDocs(q);
|
||||
return snap.docs.map((d) => d.data());
|
||||
}
|
||||
|
||||
export interface SubscribeToParticleChildrenOptions {
|
||||
onData: (children: Particle[]) => void;
|
||||
onError: (error: Error) => void;
|
||||
visibilityScopes?: string[];
|
||||
orderByField?: string;
|
||||
orderDirection?: "asc" | "desc";
|
||||
onAdded?: (child: Particle) => void;
|
||||
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
||||
whereFilter?: QueryFieldFilterConstraint;
|
||||
/** Optional cap on results. Applied after order/where constraints. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function subscribeToParticleChildren(
|
||||
collectionPath: string,
|
||||
{
|
||||
onData,
|
||||
onError,
|
||||
visibilityScopes = [],
|
||||
orderByField = "created_at",
|
||||
orderDirection = "desc",
|
||||
onAdded,
|
||||
onRemoved,
|
||||
whereFilter,
|
||||
limit: limitValue,
|
||||
}: SubscribeToParticleChildrenOptions,
|
||||
): Unsubscribe {
|
||||
let q = query(
|
||||
typedCollection(collectionPath),
|
||||
orderBy(orderByField, orderDirection),
|
||||
);
|
||||
if (visibilityScopes.length > 0) {
|
||||
q = query(q, where("visible_to", "array-contains-any", visibilityScopes));
|
||||
}
|
||||
if (whereFilter) {
|
||||
q = query(q, whereFilter);
|
||||
}
|
||||
if (limitValue !== undefined) {
|
||||
q = query(q, limit(limitValue));
|
||||
}
|
||||
return onSnapshot(
|
||||
q,
|
||||
(snap) => {
|
||||
const updatedChildren = snap.docs.map((d) => d.data());
|
||||
onData(updatedChildren);
|
||||
|
||||
if (onAdded || onRemoved) {
|
||||
for (const change of snap.docChanges()) {
|
||||
if (change.type === "added" && onAdded) onAdded(change.doc.data());
|
||||
if (change.type === "removed" && onRemoved)
|
||||
onRemoved(change.doc.data(), updatedChildren);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError,
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribeToLatestChild(
|
||||
collectionPath: string,
|
||||
onData: (child: Particle | null) => void,
|
||||
onError: (error: Error) => void,
|
||||
): Unsubscribe {
|
||||
const q = query(
|
||||
typedCollection(collectionPath),
|
||||
orderBy("created_at", "desc"),
|
||||
limit(1),
|
||||
);
|
||||
return onSnapshot(
|
||||
q,
|
||||
(snap) => {
|
||||
onData(snap.empty ? null : snap.docs[0].data());
|
||||
},
|
||||
onError,
|
||||
);
|
||||
}
|
||||
|
||||
// This creates a new particle document with the given properties and returns its ID.
|
||||
export async function createParticle<T extends ParticleType>(
|
||||
collectionPath: string,
|
||||
type: T,
|
||||
properties: ParticlePropertiesMap[T],
|
||||
createdByHumanId: string,
|
||||
// Must be passed for container types
|
||||
visibleTo?: string[],
|
||||
): Promise<string> {
|
||||
if (isContainerType(type) && (!visibleTo || visibleTo.length === 0)) {
|
||||
throw new Error(
|
||||
`visibleTo is required for container type ${type} and cannot be empty`,
|
||||
);
|
||||
}
|
||||
|
||||
const particle: Particle = ParticleSchema.parse({
|
||||
id: "", // ignored by toFirestore, but needed to satisfy the type
|
||||
type,
|
||||
properties,
|
||||
created_at: new Date(),
|
||||
created_by_human_id: createdByHumanId,
|
||||
...(visibleTo ? { visible_to: visibleTo } : {}),
|
||||
});
|
||||
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
export async function createStreamParticle(
|
||||
collectionPath: string,
|
||||
properties: ParticlePropertiesMap["stream"],
|
||||
createdByHumanId: string,
|
||||
visibleTo?: string[],
|
||||
): Promise<string> {
|
||||
if (!visibleTo || visibleTo.length === 0) {
|
||||
throw new Error("visibleTo is required for streams and cannot be empty");
|
||||
}
|
||||
|
||||
const particle: Particle = ParticleSchema.parse({
|
||||
id: "",
|
||||
type: "stream",
|
||||
properties,
|
||||
created_at: new Date(),
|
||||
created_by_human_id: createdByHumanId,
|
||||
visible_to: visibleTo,
|
||||
status: "open",
|
||||
});
|
||||
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
// This allows updating properties without overwriting the entire properties object
|
||||
export async function updateParticleProperties<T extends ParticleType>(
|
||||
docPath: string,
|
||||
properties: Partial<ParticlePropertiesMap[T]>,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
// Take the partial and create a new object with dot notation
|
||||
// e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
|
||||
const updatedProperties: Record<string, unknown> = {};
|
||||
for (const key in properties) {
|
||||
updatedProperties[`properties.${key}`] = properties[key];
|
||||
}
|
||||
await updateDoc(particleRef, {
|
||||
...updatedProperties,
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
// Edits the body of a text particle and stamps `properties.edited_at` so
|
||||
// readers can see that the message was edited (distinct from `updated_at`,
|
||||
// which is bumped by any write — visibility, reactions, etc.).
|
||||
export async function editTextParticleContent(
|
||||
docPath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
await updateDoc(particleRef, {
|
||||
"properties.content": content,
|
||||
"properties.edited_at": serverTimestamp(),
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateParticleVisibleTo(
|
||||
docPath: string,
|
||||
visibleTo: string[],
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
const particle = await getParticle(docPath);
|
||||
if (!particle) {
|
||||
throw new Error(`Particle not found at path: ${docPath}`);
|
||||
}
|
||||
if (!isContainerType(particle.type)) {
|
||||
throw new Error(
|
||||
`Only container particles can have visible_to field. Particle at ${docPath} is of type ${particle.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
await updateDoc(particleRef, {
|
||||
visible_to: visibleTo,
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
// CAUTION: use the other type safe update functions in most cases
|
||||
// There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly
|
||||
export async function updateParticle(
|
||||
docPath: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
await updateDoc(particleRef, {
|
||||
[fieldName]: value,
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateStreamStatus(
|
||||
docPath: string,
|
||||
status: "open" | "closed",
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete (tombstone) a non-container particle. The Firestore doc stays
|
||||
* in place so concurrent viewers see the deletion inline rather than being
|
||||
* bumped to an adjacent particle. Idempotent — re-calling on an already
|
||||
* tombstoned doc just refreshes the timestamp.
|
||||
*/
|
||||
export async function softDeleteParticle(
|
||||
docPath: string,
|
||||
humanId: string,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
await updateDoc(particleRef, {
|
||||
deleted_at: serverTimestamp(),
|
||||
deleted_by_human_id: humanId,
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateStreamPlaybackMarker(
|
||||
docPath: string,
|
||||
humanId: string,
|
||||
playbackPositionAt: Date,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
const markerField = `playback_markers.${humanId}`;
|
||||
await updateDoc(particleRef, {
|
||||
[markerField]: Timestamp.fromDate(playbackPositionAt),
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleParticleReaction(
|
||||
docPath: string,
|
||||
emoji: string,
|
||||
humanId: string,
|
||||
currentReactions?: Reactions,
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
const field = `reactions.${emoji}`;
|
||||
const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false;
|
||||
await updateDoc(particleRef, {
|
||||
[field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
|
||||
updated_at: serverTimestamp(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Human } from "@/api/types";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
|
||||
export const REMOVED_MEMBER_LABEL = "Removed member";
|
||||
export const REMOVED_MEMBER_INITIALS = "–";
|
||||
|
||||
export interface HumanDisplay {
|
||||
/** True when the human was found in the provided list. */
|
||||
exists: boolean;
|
||||
/** Short name for inline text (e.g. message sender). */
|
||||
displayName: string;
|
||||
/** Full email or fallback label for tooltips. */
|
||||
email: string;
|
||||
/** Initials for avatar fallback. */
|
||||
initials: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a human's display info by id, falling back consistently when the
|
||||
* human has been removed from the network. Member content (particles, reactions,
|
||||
* etc.) is retained after removal, so every render path needs a graceful fallback
|
||||
* instead of leaking raw ids into the UI.
|
||||
*/
|
||||
export function resolveHumanDisplay(
|
||||
humanId: string | null | undefined,
|
||||
humans: Human[] | undefined,
|
||||
): HumanDisplay {
|
||||
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
|
||||
if (!human) {
|
||||
return {
|
||||
exists: false,
|
||||
displayName: REMOVED_MEMBER_LABEL,
|
||||
email: REMOVED_MEMBER_LABEL,
|
||||
initials: REMOVED_MEMBER_INITIALS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
displayName: human.email_prefix,
|
||||
email: human.email,
|
||||
initials: getInitials(human.email),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* ParticlePath is a branded string type representing a URL-style path
|
||||
* to a particle in the hierarchy: /{networkId}/{segment1}/{segment2}/...
|
||||
*
|
||||
* Using a branded type prevents accidentally passing raw strings where
|
||||
* a validated particle path is expected.
|
||||
*/
|
||||
declare const __brand: unique symbol;
|
||||
export type ParticlePath = string & { readonly [__brand]: true };
|
||||
|
||||
/**
|
||||
* Construct a ParticlePath from a network ID and optional particle segments.
|
||||
*
|
||||
* @example
|
||||
* particlePath("net1", []) // => "/net1"
|
||||
* particlePath("net1", ["p1"]) // => "/net1/p1"
|
||||
* particlePath("net1", ["p1","p2"])// => "/net1/p1/p2"
|
||||
*/
|
||||
export function particlePath(
|
||||
networkId: string,
|
||||
segments: string[] = [],
|
||||
): ParticlePath {
|
||||
return `/${[networkId, ...segments].join("/")}` as ParticlePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ParticlePath back into its network ID and particle segments.
|
||||
*/
|
||||
export function parseParticlePath(path: ParticlePath): {
|
||||
networkId: string;
|
||||
segments: string[];
|
||||
} {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return { networkId: parts[0], segments: parts.slice(1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ParticlePath to the Firestore document path for that particle.
|
||||
*
|
||||
* Firestore structure:
|
||||
* /net1 → networks/net1/children (collection)
|
||||
* /net1/p1 → networks/net1/children/p1 (document)
|
||||
* /net1/p1/p2 → networks/net1/children/p1/children/p2 (document)
|
||||
*/
|
||||
export function toFirestoreDocPath(path: ParticlePath): string {
|
||||
const { networkId, segments } = parseParticlePath(path);
|
||||
const base = `networks/${networkId}/children`;
|
||||
if (segments.length === 0) return base;
|
||||
|
||||
const parts: string[] = [base, segments[0]];
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
parts.push("children", segments[i]);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ParticlePath to the Firestore collection path for its children.
|
||||
*
|
||||
* /net1 → networks/net1/children (root particles)
|
||||
* /net1/p1 → networks/net1/children/p1/children
|
||||
* /net1/p1/p2 → networks/net1/children/p1/children/p2/children
|
||||
*/
|
||||
export function toFirestoreChildrenPath(path: ParticlePath): string {
|
||||
const { segments } = parseParticlePath(path);
|
||||
if (segments.length === 0) {
|
||||
return toFirestoreDocPath(path);
|
||||
}
|
||||
return `${toFirestoreDocPath(path)}/children`;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* PusherClient manages a WebSocket connection to the pusher service.
|
||||
* Handles authentication, reconnection with exponential backoff, channel
|
||||
* subscriptions, and event dispatching.
|
||||
*
|
||||
* Identical behavior to the desktop client (js/desktop/src/lib/pusher-client.ts).
|
||||
* React Native ships a WebSocket polyfill, so this code runs unchanged.
|
||||
*/
|
||||
|
||||
import { logError, reportError } from "@/lib/errors";
|
||||
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting";
|
||||
|
||||
export interface ChannelMessage {
|
||||
humanId: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface ServerMessage {
|
||||
type: "subscribed" | "join" | "leave" | "message" | "error";
|
||||
channel?: string;
|
||||
humanId?: string;
|
||||
presence?: string[];
|
||||
payload?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type ChannelEventType = "subscribed" | "join" | "leave" | "message";
|
||||
type ChannelEventCallback = (msg: ServerMessage) => void;
|
||||
|
||||
interface PusherClientConfig {
|
||||
url: string;
|
||||
getToken: () => string | null;
|
||||
}
|
||||
|
||||
const INITIAL_RECONNECT_DELAY = 1000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const PING_INTERVAL = 20000; // 20s — keeps alive through GKE gateway timeout
|
||||
|
||||
export class PusherClient {
|
||||
private config: PusherClientConfig;
|
||||
private ws: WebSocket | null = null;
|
||||
private state: ConnectionState = "disconnected";
|
||||
private stateListeners = new Set<(state: ConnectionState) => void>();
|
||||
|
||||
private listeners = new Map<
|
||||
string,
|
||||
Map<ChannelEventType, Set<ChannelEventCallback>>
|
||||
>();
|
||||
|
||||
private activeSubscriptions = new Set<string>();
|
||||
|
||||
private reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = false;
|
||||
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(config: PusherClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
get connectionState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws) return;
|
||||
|
||||
const token = this.config.getToken();
|
||||
if (!token) {
|
||||
console.warn("[pusher] no token available, cannot connect");
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.setState(
|
||||
this.state === "reconnecting" ? "reconnecting" : "connecting",
|
||||
);
|
||||
|
||||
const url = `${this.config.url}?token=${encodeURIComponent(token)}`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.setState("connected");
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
this.startPing();
|
||||
this.resubscribeAll();
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.cleanup();
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
// onclose fires after onerror — reconnection is handled there.
|
||||
logError(event, { scope: "pusher.ws" });
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data as string);
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.clearReconnectTimer();
|
||||
this.cleanup();
|
||||
this.activeSubscriptions.clear();
|
||||
this.setState("disconnected");
|
||||
}
|
||||
|
||||
subscribe(channelId: string): void {
|
||||
this.activeSubscriptions.add(channelId);
|
||||
this.send({ type: "subscribe", channel: channelId });
|
||||
}
|
||||
|
||||
unsubscribe(channelId: string): void {
|
||||
this.activeSubscriptions.delete(channelId);
|
||||
this.send({ type: "unsubscribe", channel: channelId });
|
||||
}
|
||||
|
||||
sendMessage(channelId: string, payload: unknown): void {
|
||||
this.send({ type: "message", channel: channelId, payload });
|
||||
}
|
||||
|
||||
on(
|
||||
channelId: string,
|
||||
event: ChannelEventType,
|
||||
callback: ChannelEventCallback,
|
||||
): void {
|
||||
if (!this.listeners.has(channelId)) {
|
||||
this.listeners.set(channelId, new Map());
|
||||
}
|
||||
const channelListeners = this.listeners.get(channelId)!;
|
||||
if (!channelListeners.has(event)) {
|
||||
channelListeners.set(event, new Set());
|
||||
}
|
||||
channelListeners.get(event)!.add(callback);
|
||||
}
|
||||
|
||||
off(
|
||||
channelId: string,
|
||||
event: ChannelEventType,
|
||||
callback: ChannelEventCallback,
|
||||
): void {
|
||||
const channelListeners = this.listeners.get(channelId);
|
||||
if (!channelListeners) return;
|
||||
const eventListeners = channelListeners.get(event);
|
||||
if (!eventListeners) return;
|
||||
eventListeners.delete(callback);
|
||||
|
||||
if (eventListeners.size === 0) channelListeners.delete(event);
|
||||
if (channelListeners.size === 0) this.listeners.delete(channelId);
|
||||
}
|
||||
|
||||
onStateChange(callback: (state: ConnectionState) => void): () => void {
|
||||
this.stateListeners.add(callback);
|
||||
return () => {
|
||||
this.stateListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
private send(msg: {
|
||||
type: string;
|
||||
channel?: string;
|
||||
payload?: unknown;
|
||||
}): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: string): void {
|
||||
if (data === "pong") return;
|
||||
|
||||
let msg: ServerMessage;
|
||||
try {
|
||||
msg = JSON.parse(data);
|
||||
} catch (err) {
|
||||
logError(err, { scope: "pusher.parse", data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
logError(new Error(msg.message ?? "pusher server error"), {
|
||||
scope: "pusher.server",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg.channel) return;
|
||||
|
||||
const channelListeners = this.listeners.get(msg.channel);
|
||||
if (!channelListeners) return;
|
||||
|
||||
const eventListeners = channelListeners.get(msg.type as ChannelEventType);
|
||||
if (!eventListeners) return;
|
||||
|
||||
for (const cb of eventListeners) {
|
||||
try {
|
||||
cb(msg);
|
||||
} catch (err) {
|
||||
reportError(err, { scope: "pusher.listener", channel: msg.channel });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeAll(): void {
|
||||
for (const channelId of this.activeSubscriptions) {
|
||||
this.send({ type: "subscribe", channel: channelId });
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setState("reconnecting");
|
||||
|
||||
const jitter = Math.random() * 0.5 + 0.75;
|
||||
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectDelay = Math.min(
|
||||
this.reconnectDelay * 2,
|
||||
MAX_RECONNECT_DELAY,
|
||||
);
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.stopPing();
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.onmessage = null;
|
||||
if (
|
||||
this.ws.readyState === WebSocket.OPEN ||
|
||||
this.ws.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
this.ws.close();
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startPing(): void {
|
||||
this.stopPing();
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send("ping");
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
}
|
||||
|
||||
private stopPing(): void {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this.state === state) return;
|
||||
this.state = state;
|
||||
for (const cb of this.stateListeners) {
|
||||
cb(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { PusherClient, type ConnectionState } from "./pusher-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { appConfig } from "@/config/env";
|
||||
|
||||
const PusherContext = createContext<PusherClient | null>(null);
|
||||
const PusherStateContext = createContext<ConnectionState>("disconnected");
|
||||
|
||||
export function PusherProvider({ children }: { children: ReactNode }) {
|
||||
const token = useSessionStore((s) => s.token);
|
||||
const clientRef = useRef<PusherClient | null>(null);
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<ConnectionState>("disconnected");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
setConnectionState("disconnected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new PusherClient({
|
||||
url: appConfig.pusherUrl,
|
||||
getToken: () => useSessionStore.getState().token,
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
|
||||
const unsubscribeState = client.onStateChange((state) => {
|
||||
setConnectionState(state);
|
||||
});
|
||||
|
||||
client.connect();
|
||||
|
||||
return () => {
|
||||
unsubscribeState();
|
||||
client.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<PusherContext.Provider value={clientRef.current}>
|
||||
<PusherStateContext.Provider value={connectionState}>
|
||||
{children}
|
||||
</PusherStateContext.Provider>
|
||||
</PusherContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePusherClient(): PusherClient | null {
|
||||
return useContext(PusherContext);
|
||||
}
|
||||
|
||||
export function usePusherConnectionState(): ConnectionState {
|
||||
return useContext(PusherStateContext);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
MutationCache,
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "sonner-native";
|
||||
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
|
||||
|
||||
declare module "@tanstack/react-query" {
|
||||
interface Register {
|
||||
queryMeta: { toastOnError?: boolean };
|
||||
mutationMeta: { suppressToast?: boolean };
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetryQuery(failureCount: number, err: unknown): boolean {
|
||||
if (err instanceof ApiError) {
|
||||
// Retry only on transient status codes; 4xx generally won't succeed on retry.
|
||||
if (err.status === 408 || err.status === 429) return failureCount < 2;
|
||||
if (err.status >= 400 && err.status < 500) return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: shouldRetryQuery,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
// Mutations have side effects — never auto-retry.
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: (err, query) => {
|
||||
logError(err, { scope: "query", queryKey: query.queryKey });
|
||||
if (query.meta?.toastOnError) {
|
||||
toast.error(toUserMessage(err));
|
||||
}
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (err, _variables, _context, mutation) => {
|
||||
reportError(err, {
|
||||
scope: "mutation",
|
||||
mutationKey: mutation.options.mutationKey,
|
||||
});
|
||||
if (mutation.meta?.suppressToast) return;
|
||||
toast.error(toUserMessage(err));
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const ADJECTIVES = [
|
||||
"amber", "bold", "calm", "crisp", "dark", "eager", "faint", "gentle",
|
||||
"hasty", "icy", "jade", "keen", "lush", "misty", "nimble", "opal",
|
||||
"pale", "quiet", "rapid", "sharp", "taut", "vivid", "warm", "zesty",
|
||||
"bright", "clear", "deep", "fresh", "grand", "swift",
|
||||
];
|
||||
|
||||
const NOUNS = [
|
||||
"arrow", "bloom", "cedar", "drift", "ember", "flint", "grove", "harbor",
|
||||
"iris", "jewel", "knoll", "lake", "moss", "nova", "orbit", "petal",
|
||||
"quartz", "ridge", "spark", "trail", "vale", "wave", "yarn", "zenith",
|
||||
"brook", "cliff", "delta", "frost", "glow", "reef",
|
||||
];
|
||||
|
||||
export function generateRandomName(): string {
|
||||
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
||||
return `${adj}-${noun}`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { removeDuplicates } from "@/lib/utils";
|
||||
|
||||
const HUMAN_PREFIX = "human:";
|
||||
const NETWORK_PREFIX = "network:";
|
||||
|
||||
export type StreamVisibility =
|
||||
| { mode: "network" }
|
||||
| { mode: "custom"; humanIds: string[] };
|
||||
|
||||
export function parseVisibleTo(
|
||||
visibleTo: string[],
|
||||
networkId: string,
|
||||
): StreamVisibility {
|
||||
if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
|
||||
return { mode: "network" };
|
||||
}
|
||||
const humanIds = visibleTo
|
||||
.filter((v) => v.startsWith(HUMAN_PREFIX))
|
||||
.map((v) => v.slice(HUMAN_PREFIX.length));
|
||||
return { mode: "custom", humanIds };
|
||||
}
|
||||
|
||||
export function buildNetworkVisibility(networkId: string): string[] {
|
||||
return [`${NETWORK_PREFIX}${networkId}`];
|
||||
}
|
||||
|
||||
export function buildCustomVisibility(humanIds: string[]): string[] {
|
||||
return removeDuplicates(humanIds).map((id) => `${HUMAN_PREFIX}${id}`);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const MINUTE = 60;
|
||||
const HOUR = 3600;
|
||||
const DAY = 86400;
|
||||
const WEEK = 604800;
|
||||
const MONTH = 2592000;
|
||||
const YEAR = 31536000;
|
||||
|
||||
export function formatDistanceToNow(date: Date | string): string {
|
||||
const ms = typeof date === "string" ? new Date(date).getTime() : date.getTime();
|
||||
const seconds = Math.floor((Date.now() - ms) / 1000);
|
||||
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < MINUTE) return `${seconds}s ago`;
|
||||
if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`;
|
||||
if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`;
|
||||
if (seconds < WEEK) return `${Math.floor(seconds / DAY)}d ago`;
|
||||
if (seconds < MONTH) return `${Math.floor(seconds / WEEK)}w ago`;
|
||||
if (seconds < YEAR) return `${Math.floor(seconds / MONTH)}mo ago`;
|
||||
return `${Math.floor(seconds / YEAR)}y ago`;
|
||||
}
|
||||