From a41146fdebff50e3fad5f423748973743d388ce6 Mon Sep 17 00:00:00 2001
From: talksik
Date: Wed, 1 Apr 2026 10:03:04 -0700
Subject: [PATCH] feat: show when there is an active huddle
This introduces a webhook which listens to events from livekit and
updates our firestore stream particle. It keeps the client simple,
reacting to changes to firestore docs.
---
go/cmd/orion/main.go | 13 +-
go/go.mod | 24 ++--
go/go.sum | 39 +++++-
go/internal/handler/handler.go | 120 +++++++++++++++---
go/internal/livekit/client.go | 46 ++++++-
go/internal/particle/firestore_types.go | 9 +-
go/k8s/dev/orion.yaml | 2 +
js/src/api/client.ts | 4 +-
js/src/api/types.ts | 2 +
.../features/particles/particle-list-view.tsx | 14 +-
js/src/features/particles/stream-card.tsx | 16 ++-
js/src/features/particles/stream-view.tsx | 45 ++++++-
js/src/lib/firestore-particles.ts | 1 +
13 files changed, 285 insertions(+), 50 deletions(-)
diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go
index 0dbf063..abb7f5b 100644
--- a/go/cmd/orion/main.go
+++ b/go/cmd/orion/main.go
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
+ "cloud.google.com/go/firestore"
"cloud.google.com/go/storage"
pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal"
@@ -80,8 +81,17 @@ func main() {
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
livekitClient := livekit.NewClient()
+ // Initialize Firestore client (for webhook-driven updates)
+ gcpProject := utils.MustGetEnv("GCP_PROJECT")
+ firestoreClient, err := firestore.NewClient(ctx, gcpProject)
+ if err != nil {
+ slog.Error("failed to create Firestore client", "error", err)
+ os.Exit(1)
+ }
+ defer firestoreClient.Close()
+
// Initialize handler
- h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, livekitClient)
+ h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, livekitClient, firestoreClient)
// Helper to wrap handlers with auth middleware
withAuth := func(hf http.HandlerFunc) http.Handler {
@@ -102,6 +112,7 @@ func main() {
mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode)
mux.HandleFunc("POST /auth/sign-in", h.SignIn)
mux.HandleFunc("POST /waitlist", h.AddToWaitlist)
+ mux.HandleFunc("POST /livekit/webhook", h.HandleLivekitWebhook)
// ==========================================================================
// Protected routes (auth required)
diff --git a/go/go.mod b/go/go.mod
index fe8389d..89fd4a3 100644
--- a/go/go.mod
+++ b/go/go.mod
@@ -10,6 +10,7 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/livekit/protocol v1.45.1
+ github.com/livekit/server-sdk-go/v2 v2.16.1
github.com/redis/go-redis/v9 v9.17.2
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.11.1
@@ -41,6 +42,8 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/bep/debounce v1.2.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
@@ -54,7 +57,6 @@ require (
github.com/dennwc/iters v1.2.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
- github.com/docker/cli v29.0.0+incompatible // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
@@ -62,7 +64,7 @@ require (
github.com/ebitengine/purego v0.8.4 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
- github.com/fatih/color v1.15.0 // indirect
+ github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -72,14 +74,16 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
- github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/uuid/v5 v5.2.0 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/schema v1.3.0 // indirect
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -90,28 +94,27 @@ require (
github.com/lib/pq v1.10.9 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
+ github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 // indirect
github.com/livekit/psrpc v0.7.1 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/magefile/mage v1.15.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
- github.com/mattn/go-isatty v0.0.17 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
- github.com/moby/moby/api v1.52.0 // indirect
- github.com/moby/moby/client v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
- github.com/opencontainers/runc v1.3.3 // indirect
- github.com/ory/dockertest/v3 v3.12.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.0 // indirect
@@ -132,6 +135,10 @@ require (
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
+ github.com/prometheus/client_golang v1.22.0 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.64.0 // indirect
+ github.com/prometheus/procfs v0.19.2 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
@@ -156,6 +163,7 @@ require (
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
+ golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
diff --git a/go/go.sum b/go/go.sum
index c7fc81a..000cd72 100644
--- a/go/go.sum
+++ b/go/go.sum
@@ -50,6 +50,10 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -112,8 +116,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
-github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
-github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
@@ -162,8 +166,16 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/schema v1.3.0 h1:rbciOzXAx3IB8stEFnfTwO3sYa6EWlQk79XdyustPDA=
github.com/gorilla/schema v1.3.0/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
+github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -190,19 +202,25 @@ github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkI
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
+github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 h1:dzCBxOGLLWVtQhL7OYK2EGN+5Q+23Mq/jfz4vQisirA=
+github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22/go.mod h1:mSNtYzSf6iY9xM3UX42VEI+STHvMgHmrYzEHPcdhB8A=
github.com/livekit/protocol v1.45.1 h1:4cbynsPZW32gS2z6nUWfAfr4YaTUwZSKUiLpSpjX+lQ=
github.com/livekit/protocol v1.45.1/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0=
github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw=
github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk=
+github.com/livekit/server-sdk-go/v2 v2.16.1 h1:ZkIA9OdVvQ6Up1uW/RtQ0YJUgYMJ6+ywOmDg0jX7bTg=
+github.com/livekit/server-sdk-go/v2 v2.16.1/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
+github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
-github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -227,6 +245,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
@@ -284,6 +304,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
+github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
+github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
+github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
@@ -383,6 +411,8 @@ golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -408,6 +438,7 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go
index 0de9519..1e9e95f 100644
--- a/go/internal/handler/handler.go
+++ b/go/internal/handler/handler.go
@@ -4,10 +4,13 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"log/slog"
"net/http"
+ "strings"
"time"
+ "cloud.google.com/go/firestore"
"github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/human"
@@ -17,27 +20,30 @@ import (
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
"github.com/flowy-live/llink/internal/waitlist"
+ "github.com/livekit/protocol/webhook"
)
type Handler struct {
- authSvc auth.AuthService
- humanSvc human.Service
- networkSvc network.Service
- particleSvc particle.Service
- depotSvc depot.Service
- waitlistSvc waitlist.Service
- livekitClient livekit.Client
+ authSvc auth.AuthService
+ humanSvc human.Service
+ networkSvc network.Service
+ particleSvc particle.Service
+ depotSvc depot.Service
+ waitlistSvc waitlist.Service
+ livekitClient livekit.Client
+ firestoreClient *firestore.Client
}
-func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service, waitlistSvc waitlist.Service, livekitClient livekit.Client) *Handler {
+func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service, waitlistSvc waitlist.Service, livekitClient livekit.Client, firestoreClient *firestore.Client) *Handler {
return &Handler{
- authSvc: authSvc,
- humanSvc: humanSvc,
- networkSvc: networkSvc,
- particleSvc: particleSvc,
- depotSvc: depotSvc,
- waitlistSvc: waitlistSvc,
- livekitClient: livekitClient,
+ authSvc: authSvc,
+ humanSvc: humanSvc,
+ networkSvc: networkSvc,
+ particleSvc: particleSvc,
+ depotSvc: depotSvc,
+ waitlistSvc: waitlistSvc,
+ livekitClient: livekitClient,
+ firestoreClient: firestoreClient,
}
}
@@ -115,7 +121,8 @@ type RevokeInvitationRequest struct {
// LiveKit DTOs
type GetLivekitTokenRequest struct {
- RoomId string `json:"room_id"`
+ NetworkId string `json:"network_id"`
+ StreamId string `json:"stream_id"`
}
type GetLivekitTokenResponse struct {
@@ -1076,14 +1083,21 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) {
return
}
- if req.RoomId == "" {
- http.Error(w, "room_id is required", http.StatusBadRequest)
+ if req.NetworkId == "" {
+ http.Error(w, "network_id is required", http.StatusBadRequest)
+ return
+ }
+ if req.StreamId == "" {
+ http.Error(w, "stream_id is required", http.StatusBadRequest)
return
}
- token, err := h.livekitClient.GetJoinToken(req.RoomId, humanId, humanEmail)
+ // Compose room name encoding both network and stream IDs for webhook resolution
+ roomName := req.NetworkId + "/" + req.StreamId
+
+ token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail)
if err != nil {
- slog.Error("failed to generate livekit token", "error", err, "humanId", humanId, "roomId", req.RoomId)
+ slog.Error("failed to generate livekit token", "error", err, "humanId", humanId, "roomName", roomName)
http.Error(w, "failed to generate token", http.StatusInternalServerError)
return
}
@@ -1092,6 +1106,72 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(GetLivekitTokenResponse{Token: token, ServerUrl: h.livekitClient.ServerUrl()})
}
+// HandleLivekitWebhook processes LiveKit webhook events for huddle presence.
+// It verifies the webhook signature (not user auth), then updates the stream
+// particle's huddle_active_participants field in Firestore.
+func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
+ event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider())
+ if err != nil {
+ slog.Error("failed to verify livekit webhook", "error", err)
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ eventType := event.GetEvent()
+ slog.Info("received livekit webhook", "event", eventType, "room", event.GetRoom().GetName())
+
+ switch eventType {
+ case "participant_joined", "participant_left", "room_finished":
+ // Handle these events
+ default:
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ // Parse room name to extract networkId and streamId
+ roomName := event.GetRoom().GetName()
+ parts := strings.SplitN(roomName, "/", 2)
+ if len(parts) != 2 {
+ slog.Error("invalid room name format", "room", roomName)
+ http.Error(w, "invalid room name", http.StatusBadRequest)
+ return
+ }
+ networkId, streamId := parts[0], parts[1]
+ docPath := fmt.Sprintf("networks/%s/children/%s", networkId, streamId)
+ docRef := h.firestoreClient.Doc(docPath)
+ ctx := r.Context()
+
+ var participantIds []string
+
+ if eventType == "room_finished" {
+ // Room is done — clear the participants
+ participantIds = []string{}
+ } else {
+ // Use ListParticipants for authoritative state (avoids drift from missed webhooks)
+ participants, err := h.livekitClient.ListParticipants(ctx, roomName)
+ if err != nil {
+ slog.Error("failed to list participants", "error", err, "room", roomName)
+ // Return 200 so LiveKit doesn't retry
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ participantIds = make([]string, 0, len(participants))
+ for _, p := range participants {
+ participantIds = append(participantIds, p.Identity)
+ }
+ }
+
+ _, err = docRef.Update(ctx, []firestore.Update{
+ {Path: "huddle_active_participants", Value: participantIds},
+ })
+ if err != nil {
+ slog.Error("failed to update huddle participants in firestore", "error", err, "path", docPath)
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
diff --git a/go/internal/livekit/client.go b/go/internal/livekit/client.go
index dc57846..8cce5eb 100644
--- a/go/internal/livekit/client.go
+++ b/go/internal/livekit/client.go
@@ -1,29 +1,47 @@
package livekit
import (
+ "context"
"time"
"github.com/flowy-live/llink/internal/utils"
"github.com/livekit/protocol/auth"
+ "github.com/livekit/protocol/livekit"
+ lksdk "github.com/livekit/server-sdk-go/v2"
)
type Client interface {
- // Name will show up in the participant data
+ // GetJoinToken generates a JWT for a participant to join a room.
+ // Name will show up in the participant data.
GetJoinToken(roomId string, humanId string, name string) (string, error)
ServerUrl() string
+ // ListParticipants returns the current participants in a room.
+ ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error)
+ // KeyProvider returns the key provider for verifying webhook signatures.
+ KeyProvider() auth.KeyProvider
}
type clientImpl struct {
- apiSecret string
- apiKey string
- hostUrl string
+ apiSecret string
+ apiKey string
+ hostUrl string
+ roomService *lksdk.RoomServiceClient
+ keyProvider auth.KeyProvider
}
func NewClient() Client {
+ apiKey := utils.MustGetEnv("LIVEKIT_API_KEY")
+ apiSecret := utils.MustGetEnv("LIVEKIT_API_SECRET")
+ hostUrl := utils.MustGetEnv("LIVEKIT_URL")
+
+ roomService := lksdk.NewRoomServiceClient(hostUrl, apiKey, apiSecret)
+
return &clientImpl{
- apiSecret: utils.MustGetEnv("LIVEKIT_API_SECRET"),
- apiKey: utils.MustGetEnv("LIVEKIT_API_KEY"),
- hostUrl: utils.MustGetEnv("LIVEKIT_URL"),
+ apiSecret: apiSecret,
+ apiKey: apiKey,
+ hostUrl: hostUrl,
+ roomService: roomService,
+ keyProvider: auth.NewSimpleKeyProvider(apiKey, apiSecret),
}
}
@@ -44,3 +62,17 @@ func (c *clientImpl) GetJoinToken(room, humanId, name string) (string, error) {
return at.ToJWT()
}
+
+func (c *clientImpl) ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error) {
+ resp, err := c.roomService.ListParticipants(ctx, &livekit.ListParticipantsRequest{
+ Room: roomName,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return resp.Participants, nil
+}
+
+func (c *clientImpl) KeyProvider() auth.KeyProvider {
+ return c.keyProvider
+}
diff --git a/go/internal/particle/firestore_types.go b/go/internal/particle/firestore_types.go
index dbae226..82d5869 100644
--- a/go/internal/particle/firestore_types.go
+++ b/go/internal/particle/firestore_types.go
@@ -46,8 +46,9 @@ type FirestoreStreamParticle struct {
CreatedByHumanId string `firestore:"created_by_human_id"`
Type string `firestore:"type"`
// Properties FirestoreStreamParticleProperties `firestore:"properties"`
- CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
- LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"`
- VisibleTo []string `firestore:"visible_to"`
- UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
+ CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
+ LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"`
+ VisibleTo []string `firestore:"visible_to"`
+ UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
+ HuddleActiveParticipants []string `firestore:"huddle_active_participants,omitempty"`
}
diff --git a/go/k8s/dev/orion.yaml b/go/k8s/dev/orion.yaml
index 731c4bf..fe5a099 100644
--- a/go/k8s/dev/orion.yaml
+++ b/go/k8s/dev/orion.yaml
@@ -29,6 +29,8 @@ spec:
memory: "52Mi"
cpu: 50m
env:
+ - name: "GCP_PROJECT"
+ value: "flowy-dev-440017"
- name: "PORT"
value: "8080"
- name: "AERO_ADDR"
diff --git a/js/src/api/client.ts b/js/src/api/client.ts
index a8ab07a..bac9f8f 100644
--- a/js/src/api/client.ts
+++ b/js/src/api/client.ts
@@ -215,8 +215,8 @@ class ApiClient {
// --- LiveKit ---
- async getLivekitToken(roomId: string) {
- return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { room_id: roomId });
+ async getLivekitToken(networkId: string, streamId: string) {
+ return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
}
}
diff --git a/js/src/api/types.ts b/js/src/api/types.ts
index df346e5..3e7ef23 100644
--- a/js/src/api/types.ts
+++ b/js/src/api/types.ts
@@ -184,6 +184,8 @@ export const ParticleSchema = z.discriminatedUnion("type", [
// 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(),
}),
ParticleBaseSchema.extend({
type: z.literal("folder"), properties: FolderPropertiesSchema,
diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx
index 0a1dbec..0f6d2cb 100644
--- a/js/src/features/particles/particle-list-view.tsx
+++ b/js/src/features/particles/particle-list-view.tsx
@@ -10,6 +10,7 @@ import {
CircleCheck,
StickyNote,
Timer,
+ Phone,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -101,6 +102,10 @@ function StreamRow({
network?.message_retention_hours ?? 24,
);
+ const hasActiveHuddle =
+ particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
+ const huddleCount = particle.huddle_active_participants?.length ?? 0;
+
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
@@ -162,6 +167,7 @@ function StreamRow({
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent",
isSelected && "bg-accent",
+ hasActiveHuddle && "bg-gradient-to-r from-red-500/10 to-transparent",
)}
>
{shortcutKey && (
@@ -188,7 +194,13 @@ function StreamRow({
>
{particle.properties.name}
-
+
+ {hasActiveHuddle && (
+
+
+ {huddleCount}
+
+ )}
{expiringSoon && (
)}
diff --git a/js/src/features/particles/stream-card.tsx b/js/src/features/particles/stream-card.tsx
index a00132c..7f6b417 100644
--- a/js/src/features/particles/stream-card.tsx
+++ b/js/src/features/particles/stream-card.tsx
@@ -1,5 +1,5 @@
import { forwardRef, useMemo } from "react";
-import { Timer } from "lucide-react";
+import { Timer, Phone } from "lucide-react";
import { cn, getInitials } from "@/lib/utils";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
@@ -34,6 +34,10 @@ export const StreamCard = forwardRef
(function S
network?.message_retention_hours ?? 24,
);
+ const hasActiveHuddle =
+ particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
+ const huddleCount = particle.huddle_active_participants?.length ?? 0;
+
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
@@ -94,10 +98,14 @@ export const StreamCard = forwardRef(function S
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
isUnseen && "ring-2 ring-primary",
isSelected && "ring-2 ring-ring",
+ hasActiveHuddle && "ring-2 ring-red-500/70",
)}
>
{/* Preview area */}
+ {hasActiveHuddle && (
+
+ )}
{shortcutKey && (
{shortcutKey}
@@ -141,6 +149,12 @@ export const StreamCard = forwardRef(function S
{particle.properties.name}
+ {hasActiveHuddle && (
+
+
+ {huddleCount}
+
+ )}
{expiringSoon && (
)}
diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx
index 466a884..0450868 100644
--- a/js/src/features/particles/stream-view.tsx
+++ b/js/src/features/particles/stream-view.tsx
@@ -20,6 +20,7 @@ import { WindowControls } from "@/components/window-controls";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useStreamPlayback } from "@/hooks/use-stream-playback";
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
+import { getInitials } from "@/lib/utils";
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
@@ -173,8 +174,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
break;
case "h": {
e.preventDefault();
- const roomId = streamParticle.id;
- apiClient.getLivekitToken(roomId).then(({ token, server_url }) => {
+ apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
window.electronWindow.openHuddle({ token, serverUrl: server_url });
});
break;
@@ -330,6 +330,16 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
const navigate = useNavigate();
+ const network = useNetwork(networkId);
+
+ const huddleParticipants = streamParticle.huddle_active_participants ?? [];
+ const hasActiveHuddle = huddleParticipants.length > 0;
+
+ const handleJoinHuddle = () => {
+ apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
+ window.electronWindow.openHuddle({ token, serverUrl: server_url });
+ });
+ };
return (
@@ -356,6 +366,37 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
+ {hasActiveHuddle && (
+
+ )}
+