feature/23-voice-room/poc #93

Merged
hodlbod merged 68 commits from feature/23-voice-room/poc into dev 2026-03-16 20:38:06 +00:00
8 changed files with 386 additions and 82 deletions
Showing only changes of commit 52f2f31ce6 - Show all commits
@@ -0,0 +1,320 @@
---
name: Voice Rooms Production
overview: Implement production voice rooms by moving LiveKit token generation to the zooid relay (NIP-29 extension), making voice rooms conditional on group metadata tags, and updating flotilla to fetch tokens from the relay via NIP-98 authenticated HTTP requests.
todos:
- id: zooid-config
content: "Zooid: Add [livekit] config section (server_url, api_key, api_secret) to config.go"
status: pending
- id: zooid-livekit-go
content: "Zooid: Create livekit.go with token generation (livekit/protocol/auth), room creation (Twirp API), and response type"
status: pending
- id: zooid-endpoint
content: "Zooid: Register GET /.well-known/nip29/livekit/{groupId} endpoint on instance router with NIP-98 auth, membership check, livekit tag check"
status: pending
- id: zooid-nip98-util
content: "Zooid: Extract/reuse NIP-98 auth validation for the livekit endpoint (from api.go pattern)"
status: pending
- id: zooid-notext
content: "Zooid: Add no-text enforcement in CheckWrite -- reject non-moderation events for groups with no-text tag"
status: pending
- id: zooid-test-cp1
content: "Zooid: Test checkpoint 1+2 -- curl the token endpoint, verify livekit/no-text tag behavior"
status: pending
- id: flotilla-remove-poc
content: "Flotilla: Remove jose dependency, VITE_LIVEKIT_* env vars, client-side JWT generation, live activity (kind:30311) publishing"
status: completed
- id: flotilla-nip98-fetch
content: "Flotilla: Implement NIP-98 auth header construction and relay token HTTP fetch in voice.ts"
status: completed
- id: flotilla-update-join
content: "Flotilla: Update joinVoiceRoom/leaveVoiceRoom to use relay-provided token and server_url, use hex pubkey identity"
status: completed
- id: flotilla-presence-fix
content: "Flotilla: Simplify presence events to use h tag directly (drop a tag referencing live activity)"
status: completed
- id: flotilla-conditional-ui
content: "Flotilla: Add roomHasLivekit/roomIsNoText helpers; update SpaceMenu to conditionally render voice/text rooms"
status: completed
- id: flotilla-test-e2e
content: "Flotilla: End-to-end test -- join voice room via relay token, verify audio, verify conditional display"
status: completed
isProject: false
---
# Voice Rooms Production Implementation
This plan implements the proposed [NIP-29 LiveKit extension](https://github.com/nostr-protocol/nips/pull/2238) across two repositories: **zooid** (relay) and **flotilla** (client). Changes are organized into checkpoints so each can be tested independently. Reference implementation: [pyramid commit 4ac5418](https://github.com/fiatjaf/pyramid/commit/4ac5418175c7ea7b53e81858aa69be6f8f56c691).
---
## Architecture
```mermaid
sequenceDiagram
participant F as Flotilla Client
participant Z as Zooid Relay
participant LK as LiveKit Server
F->>Z: Subscribe kind:39000 (room metadata)
Z-->>F: Room with ["livekit"] tag
F->>F: Show voice room in sidebar
Note over F: User clicks voice room
F->>F: Build NIP-98 auth event (kind 27235)
F->>Z: GET /.well-known/nip29/livekit/{groupId}<br/>Authorization: Nostr base64(event)
Z->>Z: Verify signature, membership, livekit tag
Z->>LK: Create room if needed (Twirp API)
Z-->>F: { server_url, participant_token }
F->>LK: Connect with JWT token
F->>Z: Publish kind:10312 presence event
```
---
## Zooid Changes (relay)
### Checkpoint 1: LiveKit config and token endpoint
**Goal**: Zooid serves LiveKit JWTs to authenticated group members.
**Config changes** ([zooid/config.go](zooid/config.go)):
- Add a `[livekit]` section to the TOML config struct:
```go
Livekit struct {
ServerURL string `toml:"server_url" json:"server_url"`
APIKey string `toml:"api_key" json:"api_key"`
APISecret string `toml:"api_secret" json:"api_secret"`
} `toml:"livekit" json:"livekit"`
```
**New file** `zooid/livekit.go`:
- `generateLivekitToken(apiKey, apiSecret, room string, pubkey nostr.PubKey) string` -- Uses `github.com/livekit/protocol/auth` to create a JWT with `RoomJoin` grant and `SetIdentity(pubkey.Hex())` (hex pubkey per the NIP).
- `ensureLivekitRoom(apiKey, apiSecret, serverURL, roomName string)` -- Calls LiveKit's Twirp API to lazily create rooms. Cache known rooms in a `map[string]bool` with a mutex (same pattern as pyramid).
- `TokenEndpointResponse` struct: `{ ServerURL string, ParticipantToken string }`.
**HTTP endpoint** ([zooid/instance.go](zooid/instance.go)):
- In the router setup (after the existing `/static/` handler), register:
```go
router.HandleFunc("GET /.well-known/nip29/livekit/{groupId}", instance.livekitTokenHandler)
```
- The handler (`livekitTokenHandler` on `*Instance`):
1. Check `instance.Config.Livekit.APIKey` is configured (return 404 if not).
2. Parse `{groupId}` from the path.
3. Validate NIP-98 auth header: `Authorization: Nostr <base64-encoded-kind-27235-event>`. Reuse the same pattern from `api.go`'s `authenticateNIP98`. Verify: signature, kind == 27235, `u` tag matches the request URL, timestamp is recent.
4. Check the group exists: call `instance.Groups.GetMetadata(groupId)`.
5. Check the group's kind:39000 event has a `livekit` tag (use `HasTag(meta.Tags, "livekit")`).
6. Check the user is a group member: `instance.Groups.HasAccess(groupId, pubkey)`.
7. Ensure the LiveKit room exists.
8. Generate a token and return JSON `{ "server_url": "...", "participant_token": "..." }`.
**NIP-98 validation**: Extract from `api.go`'s `authenticateNIP98` into a shared utility in `zooid/util.go` (or call it directly), since both the REST API and the livekit endpoint need it. The key difference: the API validates `method` tag too; the livekit endpoint should also validate method is "GET".
**Dependency**: Add `github.com/livekit/protocol` to `go.mod`.
**Test**: With a zooid instance running with `[livekit]` config and a group that has a `livekit` tag on its kind:39000 event:
```bash
# Manually test with a NIP-98 auth header (use nak or a script to create the auth event)
curl -H "Authorization: Nostr <base64-event>" \
https://your-relay/.well-known/nip29/livekit/<group-id>
# Expect: { "server_url": "wss://...", "participant_token": "eyJ..." }
```
### Checkpoint 2: livekit and no-text tag support
**Goal**: Admins can set `livekit` and `no-text` tags on groups; `no-text` groups reject text events.
**Tag passthrough** -- Zooid's `UpdateMetadata` in [zooid/groups.go](zooid/groups.go) already copies all tags from edit events to the kind:39000 metadata event (converting `h` to `d`). The `livekit` and `no-text` tags will pass through naturally when an admin sends a `KindSimpleGroupEditMetadata` event containing them. No code change needed for this.
**no-text enforcement** -- In `CheckWrite` in [zooid/groups.go](zooid/groups.go), after existing access checks, add:
```go
if HasTag(meta.Tags, "no-text") &&
!slices.Contains(nip29.ModerationEventKinds, event.Kind) &&
event.Kind != nostr.KindSimpleGroupJoinRequest &&
event.Kind != nostr.KindSimpleGroupLeaveRequest {
return "blocked: this group does not allow text events"
}
```
This mirrors pyramid's `reject-event.go` logic.
**Test**:
- Send a `KindSimpleGroupEditMetadata` event with `["livekit"]` and `["no-text"]` tags. Query kind:39000 and verify the tags appear.
- Try sending a regular message to a `no-text` group; expect rejection.
- Try the token endpoint for a group with vs without the `livekit` tag; expect 403 for non-livekit groups.
---
## Flotilla Changes (client)
### Checkpoint 3: NIP-98 token fetching from relay
**Goal**: Replace client-side JWT generation with relay HTTP request.
**Remove POC artifacts** from [src/app/voice.ts](src/app/voice.ts):
- Remove the `jose` import and the `generateToken` function.
- Remove `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` constants.
- Remove `LIVE_ACTIVITY` constant and `publishLiveActivity` function (dropping kind:30311).
- Keep `ROOM_PRESENCE` and presence-related code (kind:10312).
**Remove env vars** from [.env.template](.env.template):
- Remove `VITE_LIVEKIT_URL`, `VITE_LIVEKIT_API_KEY`, `VITE_LIVEKIT_API_SECRET`.
**Remove dependency**: Run `pnpm remove jose`.
**Implement NIP-98 auth + token fetch** in [src/app/voice.ts](src/app/voice.ts):
```typescript
const fetchLivekitToken = async (
url: string,
groupId: string,
): Promise<{server_url: string; participant_token: string}> => {
const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://")
const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}`
const authEvent = await buildNip98AuthEvent(endpoint, "GET")
const encoded = btoa(JSON.stringify(authEvent))
const response = await fetch(endpoint, {
headers: {Authorization: `Nostr ${encoded}`},
})
if (!response.ok) throw new Error(`Token request failed: ${response.status}`)
return response.json()
}
```
**Build NIP-98 auth event**: Create a helper that uses the welshman signer to sign a kind 27235 event:
```typescript
const buildNip98AuthEvent = async (url: string, method: string) => {
const event = makeEvent(27235, {
tags: [
["u", url],
["method", method],
],
})
// Sign using welshman's signer
return signEvent(event)
}
```
Look at how `publishThunk` signs events for the pattern. The signer is available via `@welshman/app`. The event must be a complete signed event (with id, pubkey, sig).
**Update `joinVoiceRoom`**:
- Replace `generateToken(roomName, identity)` with `fetchLivekitToken(url, h)`.
- Use the returned `server_url` for `room.connect()` instead of the hardcoded `LIVEKIT_URL`.
- Change identity from `nip19.npubEncode(pk)` to just `pk` (hex pubkey) since the relay now sets it in the JWT. The `nip19` import can be removed.
- Remove the `publishLiveActivity` call; keep `startPresenceHeartbeat`.
- Remove `makeLivekitRoomName` since the relay now determines the room name.
**Update `leaveVoiceRoom`**:
- Remove `publishLiveActivity(session.url, session.h, "ended")` call.
**Update sync** in [src/app/core/sync.ts](src/app/core/sync.ts):
- Remove `LIVE_ACTIVITY` from the import and the subscription filter. Change `[LIVE_ACTIVITY, ROOM_PRESENCE]` to just `[ROOM_PRESENCE]`.
**Update presence derivation** in [src/app/voice.ts](src/app/voice.ts):
- Remove the reference to `LIVE_ACTIVITY` in the `a` tag matching inside `deriveVoiceParticipants`. The presence event currently references an `a` tag containing the live activity address. Since we're dropping live activities, switch to using the `h` tag directly on the presence event:
```typescript
const event = makeEvent(ROOM_PRESENCE, {
tags: [["h", h]],
})
```
And in deriveVoiceParticipants, match on the `h` tag instead of the `a` tag.
**Test**: With zooid running the checkpoint 1+2 changes, and a group with the `livekit` tag:
- Click a voice room in flotilla.
- Verify the NIP-98 HTTP request is sent to the relay.
- Verify the token is received and the LiveKit connection succeeds.
- Verify audio works between two clients.
### Checkpoint 4: Conditional voice room display
**Goal**: Only show voice rooms for groups with the `livekit` tag; show `no-text` groups as voice-only.
**Read tags from room metadata** -- The `Room` type in [src/app/core/state.ts](src/app/core/state.ts) extends `PublishedRoomMeta`, which has an `event: TrustedEvent` field. The raw kind:39000 event tags are accessible via `room.event.tags`. Check for the `livekit` tag:
```typescript
export const roomHasLivekit = (room: Room) =>
room.event?.tags?.some(t => t[0] === "livekit") ?? false
export const roomIsNoText = (room: Room) => room.event?.tags?.some(t => t[0] === "no-text") ?? false
```
Add these helpers to [src/app/core/state.ts](src/app/core/state.ts).
**Update SpaceMenu** ([src/app/components/SpaceMenu.svelte](src/app/components/SpaceMenu.svelte)):
- Currently, every room gets a `<VoiceRoomItem>` unconditionally. Change this to only render `<VoiceRoomItem>` when the room has the `livekit` tag.
- For `no-text` rooms, do NOT render `<SpaceMenuRoomItem>` (the text channel). Only render `<VoiceRoomItem>`.
- This requires deriving the room metadata for each `h` value. Use `getRoom(makeRoomId(url, h))` to access the room data and check tags:
```svelte
{#each $userRooms as h (h)}
{@const room = getRoom(makeRoomId(url, h))}
{#if !roomIsNoText(room)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
{/if}
{#if roomHasLivekit(room)}
<VoiceRoomItem {url} {h} />
{/if}
{/each}
```
Apply the same pattern to the `$roomSearch.searchValues(term)` loop.
**Test**:
- Create a group WITHOUT `livekit` tag -- verify no voice room appears.
- Add `livekit` tag to a group -- verify voice room appears alongside text channel.
- Create a group with both `livekit` and `no-text` tags -- verify only voice room appears (no text channel).
---
## Summary of changes by repo
### Zooid (relay)
| File | Change |
| ------------------------ | -------------------------------------------------------- |
| `zooid/config.go` | Add `[livekit]` config section |
| `zooid/livekit.go` (new) | Token generation, room creation, response type |
| `zooid/instance.go` | Register HTTP endpoint, add handler method |
| `zooid/util.go` | Extract NIP-98 validation from api.go (or add alongside) |
| `zooid/groups.go` | Add `no-text` enforcement in `CheckWrite` |
| `go.mod` | Add `github.com/livekit/protocol` dependency |
### Flotilla (client)
| File | Change |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `src/app/voice.ts` | Replace client-side JWT with relay HTTP fetch; add NIP-98 auth; drop live activity; simplify presence |
| `src/app/core/state.ts` | Add `roomHasLivekit()` and `roomIsNoText()` helpers |
| `src/app/core/sync.ts` | Remove `LIVE_ACTIVITY` from sync filters |
| `src/app/components/SpaceMenu.svelte` | Conditionally show voice/text rooms based on tags |
| `.env.template` | Remove `VITE_LIVEKIT_` vars |
| `package.json` | Remove `jose` dependency |
-3
View File
@@ -18,8 +18,5 @@ VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,nostr-01.uid.ovh,relay.keychat.io,relay.0xchat.com
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_LIVEKIT_URL=
VITE_LIVEKIT_API_KEY=
VITE_LIVEKIT_API_SECRET=
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
-1
View File
@@ -83,7 +83,6 @@
"fuse.js": "^7.1.0",
"husky": "^9.1.7",
"idb": "^8.0.3",
"jose": "^6.1.3",
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
-3
View File
@@ -134,9 +134,6 @@ importers:
idb:
specifier: ^8.0.3
version: 8.0.3
jose:
specifier: ^6.1.3
version: 6.1.3
livekit-client:
specifier: ^2.17.2
version: 2.17.2(@types/dom-mediacapture-record@1.0.22)
+16 -6
View File
@@ -55,6 +55,8 @@
notificationSettings,
deriveShouldNotify,
displayRoom,
roomHasLivekit,
roomIsNoText,
} from "@app/core/state"
import {setSpaceNotifications} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
@@ -257,9 +259,13 @@
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h, i (h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
<VoiceRoomItem {url} {h} />
{#each $userRooms as h (h)}
{#if !roomIsNoText(url, h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
{/if}
{#if roomHasLivekit(url, h)}
<VoiceRoomItem {url} {h} />
{/if}
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
@@ -277,9 +283,13 @@
<input bind:value={term} onblur={clearTerm} class="grow" />
</label>
{/if}
{#each $roomSearch.searchValues(term) as h, i (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
<VoiceRoomItem {url} {h} />
{#each $roomSearch.searchValues(term) as h (h)}
{#if !roomIsNoText(url, h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
{/if}
{#if roomHasLivekit(url, h)}
<VoiceRoomItem {url} {h} />
{/if}
{/each}
{#if $canCreateRoom}
hodlbod marked this conversation as resolved Outdated
Outdated
Review

I think we talked about putting voice rooms below, separate from text rooms, is that right? I can't remember, but I think it would make sense for any room with livekit support to go there instead of in the text room area regardless of whether the room has text support.

I think we talked about putting voice rooms below, separate from text rooms, is that right? I can't remember, but I think it would make sense for any room with livekit support to go there instead of in the text room area regardless of whether the room has text support.
Outdated
Review

I don't love the labels I landed on but see what you think of the current take:
"Your Rooms"
{ favorited voice and text}
"Other Rooms"
{ text rooms}
Voice Rooms
{ voice rooms}

Or when the user has no favorited rooms it looks like:
"Rooms"
{ text rooms}
Voice Rooms
{ voice rooms}

Idk if you want to say "Text Rooms" or "Chat Rooms" or something. Those feel quite wordy for menu headings to me though.

I don't love the labels I landed on but see what you think of the current take: "Your Rooms" { favorited voice and text} "Other Rooms" { text rooms} Voice Rooms { voice rooms} Or when the user has no favorited rooms it looks like: "Rooms" { text rooms} Voice Rooms { voice rooms} Idk if you want to say "Text Rooms" or "Chat Rooms" or something. Those feel quite wordy for menu headings to me though.
Outdated
Review

This surfaces another weird consequence of rooms that are "livekit" but not "no-text": currently favoriting either the text or vioce channel pulls both into the "Your Rooms" section.

This surfaces another weird consequence of rooms that are "livekit" but not "no-text": currently favoriting either the text or vioce channel pulls both into the "Your Rooms" section.
Outdated
Review

That's probably ok, I like how the labels look.

That's probably ok, I like how the labels look.
<SecondaryNavItem {replaceState} onclick={addRoom}>
+10
View File
3
@@ -663,6 +663,16 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
export const roomHasLivekit = (url: string, h: string) => {
const room = getRoom(makeRoomId(url, h))
hodlbod marked this conversation as resolved Outdated
Outdated
Review

It looks like these utilities aren't used

It looks like these utilities aren't used
return room?.event?.tags?.some(t => t[0] === "livekit") ?? false
}
export const roomIsNoText = (url: string, h: string) => {
const room = getRoom(makeRoomId(url, h))
return room?.event?.tags?.some(t => t[0] === "no-text") ?? false
}
// User space/room lists
export const groupListsByPubkey = deriveItemsByKey({
+2 -2
View File
@@ -55,7 +55,7 @@ import {
loadFeedsForPubkey,
} from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands"
import {LIVE_ACTIVITY, ROOM_PRESENCE} from "@app/voice"
import {ROOM_PRESENCE} from "@app/voice"
// Utils
@@ -320,7 +320,7 @@ const syncSpace = (url: string, rooms: string[]) => {
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [LIVE_ACTIVITY, ROOM_PRESENCE]}],
filters: [{kinds: [ROOM_PRESENCE]}],
})
return () => controller.abort()
+38 -67
View File
@@ -1,18 +1,12 @@
import {derived, get, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {SignJWT} from "jose"
import {Room, RoomEvent, Track} from "livekit-client"
import {now} from "@welshman/lib"
import {makeEvent, normalizeRelayUrl, getTag} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app"
import {deriveEventsForUrl, displayRoom} from "@app/core/state"
import {makeEvent, getTagValue} from "@welshman/util"
import {signer, publishThunk} from "@welshman/app"
import {deriveEventsForUrl} from "@app/core/state"
export const LIVE_ACTIVITY = 30311
export const ROOM_PRESENCE = 10312
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || ""
const LIVEKIT_API_KEY = import.meta.env.VITE_LIVEKIT_API_KEY || ""
const LIVEKIT_API_SECRET = import.meta.env.VITE_LIVEKIT_API_SECRET || ""
const PRESENCE_INTERVAL_MS = 60_000
const PRESENCE_EXPIRY_S = 300
@@ -25,23 +19,40 @@ export type VoiceSession = {
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
const makeLivekitRoomName = (url: string, h: string) =>
`${normalizeRelayUrl(url)}:${h}`.replace(/[^a-zA-Z0-9_-]/g, "_")
const buildNip98AuthEvent = async (url: string, method: string) => {
const $signer = signer.get()
if (!$signer) throw new Error("No signer available")
const generateToken = async (roomName: string, identity: string) => {
const secret = new TextEncoder().encode(LIVEKIT_API_SECRET)
const jwt = await new SignJWT({
video: {roomJoin: true, room: roomName, canPublish: true, canSubscribe: true},
sub: identity,
iss: LIVEKIT_API_KEY,
jti: identity,
const event = makeEvent(27235, {
tags: [
["u", url],
["method", method],
],
})
.setProtectedHeader({alg: "HS256"})
.setIssuedAt()
.setExpirationTime("6h")
.sign(secret)
return jwt
return $signer.sign(event)
}
const fetchLivekitToken = async (
url: string,
groupId: string,
): Promise<{server_url: string; participant_token: string}> => {
const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://")
const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}`
const authEvent = await buildNip98AuthEvent(endpoint, "GET")
const encoded = btoa(JSON.stringify(authEvent))
const response = await fetch(endpoint, {
headers: {Authorization: `Nostr ${encoded}`},
})
hodlbod marked this conversation as resolved Outdated
Outdated
Review

[nit] simpler would be writable(new Set<string>())

[nit] simpler would be `writable(new Set<string>())`
if (!response.ok) {
const text = await response.text()
throw new Error(`Token request failed (${response.status}): ${text}`)
}
return response.json()
}
export const deriveVoiceParticipants = (url: string, h: string) =>
@@ -52,11 +63,7 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
for (const event of $events) {
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Use welshman's version instead (makeHttpAuth/makeHttpAuthHeader)

Use welshman's version instead (makeHttpAuth/makeHttpAuthHeader)
if (event.created_at < cutoff) continue
const aTag = getTag("a", event.tags)
if (!aTag) continue
const [, , dTag] = aTag[1].split(":")
if (dTag === h) {
if (getTagValue("h", event.tags) === h) {
pubkeys.push(event.pubkey)
}
}
@@ -64,29 +71,9 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
return pubkeys
})
const publishLiveActivity = (url: string, h: string, status: "live" | "ended") => {
const pk = get(pubkey)!
const title = displayRoom(url, h)
const event = makeEvent(LIVE_ACTIVITY, {
tags: [
["d", h],
["h", h],
["title", title],
["service", LIVEKIT_URL],
["status", status],
["starts", String(now())],
["p", pk, "", "Host"],
],
})
return publishThunk({event, relays: [url]})
}
const publishPresence = (url: string, h: string) => {
const pk = get(pubkey)!
const aTag = `${LIVE_ACTIVITY}:${pk}:${h}`
const event = makeEvent(ROOM_PRESENCE, {
tags: [["a", aTag, url, "root"]],
tags: [["h", h]],
})
return publishThunk({event, relays: [url]})
2
@@ -121,10 +108,7 @@ export const joinVoiceRoom = async (url: string, h: string) => {
await leaveVoiceRoom()
}
const pk = get(pubkey)!
const identity = nip19.npubEncode(pk)
const roomName = makeLivekitRoomName(url, h)
const token = await generateToken(roomName, identity)
const {server_url, participant_token} = await fetchLivekitToken(url, h)
const room = new Room({
adaptiveStream: true,
@@ -149,12 +133,11 @@ export const joinVoiceRoom = async (url: string, h: string) => {
track.detach().forEach(el => el.remove())
})
await room.connect(LIVEKIT_URL, token)
await room.connect(server_url, participant_token)
await room.localParticipant.setMicrophoneEnabled(true)
currentVoiceSession.set({url, h, room, muted: false})
publishLiveActivity(url, h, "live")
startPresenceHeartbeat(url, h)
}
@@ -165,7 +148,6 @@ export const leaveVoiceRoom = async () => {
stopPresenceHeartbeat()
session.room.disconnect()
deletePresence(session.url)
publishLiveActivity(session.url, session.h, "ended")
currentVoiceSession.set(undefined)
}
@@ -177,14 +159,3 @@ export const toggleMute = () => {
session.room.localParticipant.setMicrophoneEnabled(!muted)
currentVoiceSession.set({...session, muted})
}
export const toggleDeafen = () => {
const session = get(currentVoiceSession)
if (!session) return
for (const participant of session.room.remoteParticipants.values()) {
for (const pub of participant.audioTrackPublications.values()) {
pub.setEnabled(!pub.isEnabled)
}
}
}
4