screen sharing working

This commit is contained in:
2026-03-30 17:41:54 -04:00
parent 76ed6d7ab3
commit ec66d6a9e6
62 changed files with 6727 additions and 3346 deletions
+155 -81
View File
@@ -9,7 +9,7 @@
## Language-Specific Rules
| Language | Rule File |
|----------|-----------|
| ----------------------- | ---------------------------------------- |
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
@@ -44,6 +44,7 @@ When implementing a feature that spans backend and client:
## Index System
SpacetimeDB automatically creates indexes for:
- Primary key columns
- Columns marked as unique
@@ -52,6 +53,7 @@ You can add explicit indexes on non-unique columns for query performance.
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
**Schema ↔ Code coupling:**
- Your query code references indexes by name
- If you add/remove/rename an index in the schema, update all code that uses it
- Removing an index without updating queries causes runtime errors
@@ -85,7 +87,7 @@ spacetime logs <db-name>
## Deployment
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
- The default server marked by *** in `spacetime server list` should be used when publishing
- The default server marked by \*\*\* in `spacetime server list` should be used when publishing
- If the default server is maincloud you should publish to maincloud
- Publishing to maincloud is free of charge
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
@@ -110,7 +112,6 @@ spacetime logs <db-name>
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
# SpacetimeDB TypeScript SDK
## ⛔ HALLUCINATED APIs — DO NOT USE
@@ -139,11 +140,11 @@ tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object!
```typescript
// ✅ CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
import { DbConnection, tables } from "./module_bindings"; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from "spacetimedb/react";
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.doSomething({ value: "test" });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
@@ -151,6 +152,7 @@ const [items, isLoading] = useTable(tables.item);
```
### ⛔ DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
@@ -161,7 +163,7 @@ const [items, isLoading] = useTable(tables.item);
### Server-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| ------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------ |
| Missing `package.json` | Create `package.json` | "could not detect language" |
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
@@ -187,7 +189,7 @@ const [items, isLoading] = useTable(tables.item);
### Client-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| ------------------------------------- | ------------------------------------------- | ----------------------- |
| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
@@ -202,62 +204,77 @@ const [items, isLoading] = useTable(tables.item);
**`table()` takes TWO arguments: `table(OPTIONS, COLUMNS)`**
```typescript
import { schema, table, t } from 'spacetimedb/server';
import { schema, table, t } from "spacetimedb/server";
// ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error
export const Task = table({ name: 'task' }, {
export const Task = table(
{ name: "task" },
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] // ❌ WRONG!
});
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }], // ❌ WRONG!
},
);
// ✅ RIGHT — indexes in OPTIONS (first argument)
export const Task = table({
name: 'task',
export const Task = table(
{
name: "task",
public: true,
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
}, {
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
},
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
title: t.string(),
createdAt: t.timestamp(),
});
},
);
```
### Column types
```typescript
t.identity() // User identity (primary key for per-user tables)
t.u64() // Unsigned 64-bit integer (use for IDs)
t.string() // Text
t.bool() // Boolean
t.timestamp() // Timestamp (use ctx.timestamp for current time)
t.scheduleAt() // For scheduled tables only
t.identity(); // User identity (primary key for per-user tables)
t.u64(); // Unsigned 64-bit integer (use for IDs)
t.string(); // Text
t.bool(); // Boolean
t.timestamp(); // Timestamp (use ctx.timestamp for current time)
t.scheduleAt(); // For scheduled tables only
// Product types (nested objects) — use t.object, NOT t.struct
const Point = t.object('Point', { x: t.i32(), y: t.i32() });
const Point = t.object("Point", { x: t.i32(), y: t.i32() });
// Sum types (tagged unions) — use t.enum, NOT t.sum
const Shape = t.enum('Shape', { circle: t.i32(), rectangle: Point });
const Shape = t.enum("Shape", { circle: t.i32(), rectangle: Point });
// Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } }
// Modifiers
t.string().optional() // Nullable
t.u64().primaryKey() // Primary key
t.u64().primaryKey().autoInc() // Auto-increment primary key
t.string().optional(); // Nullable
t.u64().primaryKey(); // Primary key
t.u64().primaryKey().autoInc(); // Auto-increment primary key
```
> ⚠️ **BIGINT SYNTAX:** All `u64`, `i64`, and ID fields use JavaScript BigInt.
>
> - Literals: `0n`, `1n`, `100n` (NOT `0`, `1`, `100`)
> - Comparisons: `row.id === 5n` (NOT `row.id === 5`)
> - Arithmetic: `row.count + 1n` (NOT `row.count + 1`)
### Auto-increment placeholder
```typescript
// ✅ MUST provide 0n placeholder for auto-inc fields
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title: 'New', createdAt: ctx.timestamp });
ctx.db.task.insert({
id: 0n,
ownerId: ctx.sender,
title: "New",
createdAt: ctx.timestamp,
});
```
### Insert returns ROW, not ID
```typescript
// ❌ WRONG
const id = ctx.db.task.insert({ ... });
@@ -268,6 +285,7 @@ const newId = row.id; // Extract .id from returned row
```
### Schema export (CRITICAL)
```typescript
// At end of schema.ts — schema() takes exactly ONE argument: an object
const spacetimedb = schema({ table1, table2, table3 });
@@ -294,7 +312,9 @@ const msgs = [...ctx.db.message.message_room_id.filter(roomId)];
// 3. NO INDEX — use .iter() + manual filter
for (const m of ctx.db.roomMember.iter()) {
if (m.roomId === roomId) { /* ... */ }
if (m.roomId === roomId) {
/* ... */
}
}
```
@@ -302,24 +322,31 @@ for (const m of ctx.db.roomMember.iter()) {
```typescript
// In table OPTIONS (first argument), not columns
export const Message = table({
name: 'message',
export const Message = table(
{
name: "message",
public: true,
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
}, {
indexes: [
{ name: "message_room_id", algorithm: "btree", columns: ["roomId"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
roomId: t.u64(),
// ...
});
},
);
```
### Naming conventions
**Table names — automatic transformation:**
- Schema: `table({ name: 'my_messages' })`
- Access: `ctx.db.myMessages` (automatic snake_case → camelCase)
**Index names — NO transformation, use EXACTLY as defined:**
```typescript
// Schema definition
indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }]
@@ -335,6 +362,7 @@ ctx.db.canvasMember.canvas_member_canvas_id.filter(...)
> ⚠️ **Index names are used VERBATIM** — pick a convention (snake_case or camelCase) and stick with it.
**Index naming pattern — use `{tableName}_{columnName}`:**
```typescript
// ✅ GOOD — unique names across entire module
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
@@ -346,10 +374,12 @@ indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT!
```
**Client-side table names:**
- Check generated `module_bindings/index.ts` for exact export names
- Usage: `useTable(tables.MyMessages)` or `tables.myMessages` (varies by SDK version)
### Filter vs Find
```typescript
// Filter takes VALUE directly, not object — returns iterator
const rows = [...ctx.db.task.by_owner.filter(ownerId)];
@@ -359,13 +389,16 @@ const row = ctx.db.player.identity.find(ctx.sender);
```
### ⚠️ Multi-column indexes are BROKEN
```typescript
// ❌ DON'T — causes PANIC
ctx.db.scores.by_player_level.filter(playerId);
// ✅ DO — use single-column index + manual filter
for (const row of ctx.db.scores.by_player.filter(playerId)) {
if (row.level === targetLevel) { /* ... */ }
if (row.level === targetLevel) {
/* ... */
}
}
```
@@ -374,6 +407,7 @@ for (const row of ctx.db.scores.by_player.filter(playerId)) {
## 4) Reducers
### Definition syntax (CRITICAL)
**Reducer name comes from the export — NOT from a string argument.** Use `reducer(params, fn)` or `reducer(fn)`.
```typescript
@@ -403,17 +437,23 @@ spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) =>
```
### Update pattern (CRITICAL)
```typescript
// ✅ CORRECT — spread existing row, override specific fields
const existing = ctx.db.task.id.find(taskId);
if (!existing) throw new SenderError('Task not found');
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
if (!existing) throw new SenderError("Task not found");
ctx.db.task.id.update({
...existing,
title: newTitle,
updatedAt: ctx.timestamp,
});
// ❌ WRONG — partial update nulls out other fields!
ctx.db.task.id.update({ id: taskId, title: newTitle });
```
### Delete pattern
```typescript
// Delete by primary key VALUE (not row object)
ctx.db.task.id.delete(taskId); // taskId is the u64 value
@@ -421,6 +461,7 @@ ctx.db.player.identity.delete(ctx.sender); // delete by identity
```
### Lifecycle hooks
```typescript
spacetimedb.clientConnected((ctx) => {
// ctx.sender is the connecting identity
@@ -433,16 +474,18 @@ spacetimedb.clientDisconnected((ctx) => {
```
### Snake_case to camelCase conversion
- Server: `export const do_something = spacetimedb.reducer(...)` — name from export
- Client: `conn.reducers.doSomething({ ... })`
### Object syntax required
```typescript
// ❌ WRONG - positional
conn.reducers.doSomething('value');
conn.reducers.doSomething("value");
// ✅ RIGHT - object
conn.reducers.doSomething({ param: 'value' });
conn.reducers.doSomething({ param: "value" });
```
---
@@ -451,28 +494,34 @@ conn.reducers.doSomething({ param: 'value' });
```typescript
// 1. Define table first (scheduled: () => reducer — pass the exported reducer)
export const CleanupJob = table({
name: 'cleanup_job',
scheduled: () => run_cleanup // reducer defined below
}, {
export const CleanupJob = table(
{
name: "cleanup_job",
scheduled: () => run_cleanup, // reducer defined below
},
{
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
targetId: t.u64(), // Your custom data
});
},
);
// 2. Define scheduled reducer (receives full row as arg)
export const run_cleanup = spacetimedb.reducer({ arg: CleanupJob.rowType }, (ctx, { arg }) => {
export const run_cleanup = spacetimedb.reducer(
{ arg: CleanupJob.rowType },
(ctx, { arg }) => {
// arg.scheduledId, arg.targetId available
// Row is auto-deleted after reducer completes
});
},
);
// Schedule a job
import { ScheduleAt } from 'spacetimedb';
import { ScheduleAt } from "spacetimedb";
const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
ctx.db.cleanupJob.insert({
scheduledId: 0n,
scheduledAt: ScheduleAt.time(futureTime),
targetId: someId
targetId: someId,
});
// Cancel a job by deleting the row
@@ -484,8 +533,9 @@ ctx.db.cleanupJob.scheduledId.delete(jobId);
## 6) Timestamps
### Server-side
```typescript
import { Timestamp, ScheduleAt } from 'spacetimedb';
import { Timestamp, ScheduleAt } from "spacetimedb";
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
@@ -495,7 +545,9 @@ const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
```
### Client-side (CRITICAL)
**Timestamps are objects, not numbers:**
```typescript
// ❌ WRONG
const date = new Date(row.createdAt);
@@ -506,9 +558,10 @@ const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
### ScheduleAt on client
```typescript
// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
if (scheduleAt.tag === "Time") {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
```
@@ -520,31 +573,36 @@ if (scheduleAt.tag === 'Time') {
**`public: true` exposes ALL rows to ALL clients.**
| Scenario | Pattern |
|----------|---------|
| ------------------------- | ------------------------------------- |
| Everyone sees all rows | `public: true` |
| Users see only their data | Private table + filtered subscription |
### Subscription patterns (client-side)
```typescript
// Subscribe to ALL public tables (simplest)
conn.subscriptionBuilder().subscribeToAll();
// Subscribe to specific tables with SQL
conn.subscriptionBuilder().subscribe([
'SELECT * FROM message',
'SELECT * FROM room WHERE is_public = true',
conn
.subscriptionBuilder()
.subscribe([
"SELECT * FROM message",
"SELECT * FROM room WHERE is_public = true",
]);
// Handle subscription lifecycle
conn.subscriptionBuilder()
.onApplied(() => console.log('Initial data loaded'))
.onError((e) => console.error('Subscription failed:', e))
conn
.subscriptionBuilder()
.onApplied(() => console.log("Initial data loaded"))
.onError((e) => console.error("Subscription failed:", e))
.subscribeToAll();
```
### Private table + view pattern (RECOMMENDED)
**Views are the recommended approach** for controlling data visibility. They provide:
- Server-side filtering (reduces network traffic)
- Real-time updates when underlying data changes
- Full control over what data clients can access
@@ -557,28 +615,29 @@ conn.subscriptionBuilder()
```typescript
// Private table with index on ownerId
export const PrivateData = table(
{ name: 'private_data',
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
{
name: "private_data",
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
},
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
secret: t.string()
}
secret: t.string(),
},
);
// ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change)
spacetimedb.view(
{ name: 'my_data_slow', public: true },
{ name: "my_data_slow", public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.iter()] // Works but VERY slow at scale
(ctx) => [...ctx.db.privateData.iter()], // Works but VERY slow at scale
);
// ✅ GOOD — index lookup enables targeted invalidation
spacetimedb.view(
{ name: 'my_data', public: true },
{ name: "my_data", public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)]
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)],
);
```
@@ -588,32 +647,40 @@ spacetimedb.view(
// Query-builder views return a query; the SQL engine maintains the result incrementally.
// This can scan the whole table if needed (e.g. leaderboard-style queries).
spacetimedb.anonymousView(
{ name: 'top_players', public: true },
{ name: "top_players", public: true },
t.array(Player.rowType),
(ctx) =>
ctx.from.player
.where(p => p.score.gt(1000))
(ctx) => ctx.from.player.where((p) => p.score.gt(1000)),
);
```
### ViewContext vs AnonymousViewContext
```typescript
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
spacetimedb.view(
{ name: "my_items", public: true },
t.array(Item.rowType),
(ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
},
);
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
spacetimedb.anonymousView(
{ name: "leaderboard", public: true },
t.array(LeaderboardRow),
(ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});
},
);
```
**Views require explicit subscription:**
```typescript
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
"SELECT * FROM public_table",
"SELECT * FROM my_data", // Views need explicit SQL!
]);
```
@@ -622,16 +689,18 @@ conn.subscriptionBuilder().subscribe([
## 8) React Integration
### Key patterns
```typescript
// Memoize connectionBuilder to prevent reconnects on re-render
const builder = useMemo(() =>
const builder = useMemo(
() =>
DbConnection.builder()
.withUri(SPACETIMEDB_URI)
.withDatabaseName(MODULE_NAME)
.withToken(localStorage.getItem('auth_token') || undefined)
.withToken(localStorage.getItem("auth_token") || undefined)
.onConnect(onConnect)
.onConnectError(onConnectError),
[] // Empty deps - only create once
[], // Empty deps - only create once
);
// useTable returns tuple [rows, isLoading]
@@ -650,6 +719,7 @@ const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();
⚠️ Procedures are currently in beta. API may change.
### Defining a procedure
**Procedure name comes from the export — NOT from a string argument.** Use `procedure(params, ret, fn)` or `procedure(ret, fn)`.
```typescript
@@ -660,7 +730,7 @@ export const fetch_external_data = spacetimedb.procedure(
(ctx, { url }) => {
const response = ctx.http.fetch(url);
return response.text();
}
},
);
```
@@ -692,8 +762,9 @@ spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
```
### Key differences from reducers
| Reducers | Procedures |
|----------|------------|
| --------------------------- | ------------------------------------- |
| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` |
| Automatic transaction | Manual transaction management |
| No HTTP/network | `ctx.http.fetch()` available |
@@ -704,6 +775,7 @@ spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
## 10) Project Structure
### Server (`backend/spacetimedb/`)
```
src/schema.ts → Tables, export spacetimedb
src/index.ts → Reducers, lifecycle, import schema
@@ -712,12 +784,14 @@ tsconfig.json → Standard config
```
### Avoiding circular imports
```
schema.ts → defines tables AND exports spacetimedb
index.ts → imports spacetimedb from ./schema, defines reducers
```
### Client (`client/`)
```
src/module_bindings/ → Generated (spacetime generate)
src/main.tsx → Provider, connection setup
+155 -81
View File
@@ -9,7 +9,7 @@
## Language-Specific Rules
| Language | Rule File |
|----------|-----------|
| ----------------------- | ---------------------------------------- |
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
@@ -44,6 +44,7 @@ When implementing a feature that spans backend and client:
## Index System
SpacetimeDB automatically creates indexes for:
- Primary key columns
- Columns marked as unique
@@ -52,6 +53,7 @@ You can add explicit indexes on non-unique columns for query performance.
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
**Schema ↔ Code coupling:**
- Your query code references indexes by name
- If you add/remove/rename an index in the schema, update all code that uses it
- Removing an index without updating queries causes runtime errors
@@ -85,7 +87,7 @@ spacetime logs <db-name>
## Deployment
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
- The default server marked by *** in `spacetime server list` should be used when publishing
- The default server marked by \*\*\* in `spacetime server list` should be used when publishing
- If the default server is maincloud you should publish to maincloud
- Publishing to maincloud is free of charge
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
@@ -110,7 +112,6 @@ spacetime logs <db-name>
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
# SpacetimeDB TypeScript SDK
## ⛔ HALLUCINATED APIs — DO NOT USE
@@ -139,11 +140,11 @@ tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object!
```typescript
// ✅ CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
import { DbConnection, tables } from "./module_bindings"; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from "spacetimedb/react";
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.doSomething({ value: "test" });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
@@ -151,6 +152,7 @@ const [items, isLoading] = useTable(tables.item);
```
### ⛔ DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
@@ -161,7 +163,7 @@ const [items, isLoading] = useTable(tables.item);
### Server-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| ------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------ |
| Missing `package.json` | Create `package.json` | "could not detect language" |
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
@@ -187,7 +189,7 @@ const [items, isLoading] = useTable(tables.item);
### Client-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| ------------------------------------- | ------------------------------------------- | ----------------------- |
| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
@@ -202,62 +204,77 @@ const [items, isLoading] = useTable(tables.item);
**`table()` takes TWO arguments: `table(OPTIONS, COLUMNS)`**
```typescript
import { schema, table, t } from 'spacetimedb/server';
import { schema, table, t } from "spacetimedb/server";
// ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error
export const Task = table({ name: 'task' }, {
export const Task = table(
{ name: "task" },
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] // ❌ WRONG!
});
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }], // ❌ WRONG!
},
);
// ✅ RIGHT — indexes in OPTIONS (first argument)
export const Task = table({
name: 'task',
export const Task = table(
{
name: "task",
public: true,
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
}, {
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
},
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
title: t.string(),
createdAt: t.timestamp(),
});
},
);
```
### Column types
```typescript
t.identity() // User identity (primary key for per-user tables)
t.u64() // Unsigned 64-bit integer (use for IDs)
t.string() // Text
t.bool() // Boolean
t.timestamp() // Timestamp (use ctx.timestamp for current time)
t.scheduleAt() // For scheduled tables only
t.identity(); // User identity (primary key for per-user tables)
t.u64(); // Unsigned 64-bit integer (use for IDs)
t.string(); // Text
t.bool(); // Boolean
t.timestamp(); // Timestamp (use ctx.timestamp for current time)
t.scheduleAt(); // For scheduled tables only
// Product types (nested objects) — use t.object, NOT t.struct
const Point = t.object('Point', { x: t.i32(), y: t.i32() });
const Point = t.object("Point", { x: t.i32(), y: t.i32() });
// Sum types (tagged unions) — use t.enum, NOT t.sum
const Shape = t.enum('Shape', { circle: t.i32(), rectangle: Point });
const Shape = t.enum("Shape", { circle: t.i32(), rectangle: Point });
// Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } }
// Modifiers
t.string().optional() // Nullable
t.u64().primaryKey() // Primary key
t.u64().primaryKey().autoInc() // Auto-increment primary key
t.string().optional(); // Nullable
t.u64().primaryKey(); // Primary key
t.u64().primaryKey().autoInc(); // Auto-increment primary key
```
> ⚠️ **BIGINT SYNTAX:** All `u64`, `i64`, and ID fields use JavaScript BigInt.
>
> - Literals: `0n`, `1n`, `100n` (NOT `0`, `1`, `100`)
> - Comparisons: `row.id === 5n` (NOT `row.id === 5`)
> - Arithmetic: `row.count + 1n` (NOT `row.count + 1`)
### Auto-increment placeholder
```typescript
// ✅ MUST provide 0n placeholder for auto-inc fields
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title: 'New', createdAt: ctx.timestamp });
ctx.db.task.insert({
id: 0n,
ownerId: ctx.sender,
title: "New",
createdAt: ctx.timestamp,
});
```
### Insert returns ROW, not ID
```typescript
// ❌ WRONG
const id = ctx.db.task.insert({ ... });
@@ -268,6 +285,7 @@ const newId = row.id; // Extract .id from returned row
```
### Schema export (CRITICAL)
```typescript
// At end of schema.ts — schema() takes exactly ONE argument: an object
const spacetimedb = schema({ table1, table2, table3 });
@@ -294,7 +312,9 @@ const msgs = [...ctx.db.message.message_room_id.filter(roomId)];
// 3. NO INDEX — use .iter() + manual filter
for (const m of ctx.db.roomMember.iter()) {
if (m.roomId === roomId) { /* ... */ }
if (m.roomId === roomId) {
/* ... */
}
}
```
@@ -302,24 +322,31 @@ for (const m of ctx.db.roomMember.iter()) {
```typescript
// In table OPTIONS (first argument), not columns
export const Message = table({
name: 'message',
export const Message = table(
{
name: "message",
public: true,
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
}, {
indexes: [
{ name: "message_room_id", algorithm: "btree", columns: ["roomId"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
roomId: t.u64(),
// ...
});
},
);
```
### Naming conventions
**Table names — automatic transformation:**
- Schema: `table({ name: 'my_messages' })`
- Access: `ctx.db.myMessages` (automatic snake_case → camelCase)
**Index names — NO transformation, use EXACTLY as defined:**
```typescript
// Schema definition
indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }]
@@ -335,6 +362,7 @@ ctx.db.canvasMember.canvas_member_canvas_id.filter(...)
> ⚠️ **Index names are used VERBATIM** — pick a convention (snake_case or camelCase) and stick with it.
**Index naming pattern — use `{tableName}_{columnName}`:**
```typescript
// ✅ GOOD — unique names across entire module
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
@@ -346,10 +374,12 @@ indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT!
```
**Client-side table names:**
- Check generated `module_bindings/index.ts` for exact export names
- Usage: `useTable(tables.MyMessages)` or `tables.myMessages` (varies by SDK version)
### Filter vs Find
```typescript
// Filter takes VALUE directly, not object — returns iterator
const rows = [...ctx.db.task.by_owner.filter(ownerId)];
@@ -359,13 +389,16 @@ const row = ctx.db.player.identity.find(ctx.sender);
```
### ⚠️ Multi-column indexes are BROKEN
```typescript
// ❌ DON'T — causes PANIC
ctx.db.scores.by_player_level.filter(playerId);
// ✅ DO — use single-column index + manual filter
for (const row of ctx.db.scores.by_player.filter(playerId)) {
if (row.level === targetLevel) { /* ... */ }
if (row.level === targetLevel) {
/* ... */
}
}
```
@@ -374,6 +407,7 @@ for (const row of ctx.db.scores.by_player.filter(playerId)) {
## 4) Reducers
### Definition syntax (CRITICAL)
**Reducer name comes from the export — NOT from a string argument.** Use `reducer(params, fn)` or `reducer(fn)`.
```typescript
@@ -403,17 +437,23 @@ spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) =>
```
### Update pattern (CRITICAL)
```typescript
// ✅ CORRECT — spread existing row, override specific fields
const existing = ctx.db.task.id.find(taskId);
if (!existing) throw new SenderError('Task not found');
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
if (!existing) throw new SenderError("Task not found");
ctx.db.task.id.update({
...existing,
title: newTitle,
updatedAt: ctx.timestamp,
});
// ❌ WRONG — partial update nulls out other fields!
ctx.db.task.id.update({ id: taskId, title: newTitle });
```
### Delete pattern
```typescript
// Delete by primary key VALUE (not row object)
ctx.db.task.id.delete(taskId); // taskId is the u64 value
@@ -421,6 +461,7 @@ ctx.db.player.identity.delete(ctx.sender); // delete by identity
```
### Lifecycle hooks
```typescript
spacetimedb.clientConnected((ctx) => {
// ctx.sender is the connecting identity
@@ -433,16 +474,18 @@ spacetimedb.clientDisconnected((ctx) => {
```
### Snake_case to camelCase conversion
- Server: `export const do_something = spacetimedb.reducer(...)` — name from export
- Client: `conn.reducers.doSomething({ ... })`
### Object syntax required
```typescript
// ❌ WRONG - positional
conn.reducers.doSomething('value');
conn.reducers.doSomething("value");
// ✅ RIGHT - object
conn.reducers.doSomething({ param: 'value' });
conn.reducers.doSomething({ param: "value" });
```
---
@@ -451,28 +494,34 @@ conn.reducers.doSomething({ param: 'value' });
```typescript
// 1. Define table first (scheduled: () => reducer — pass the exported reducer)
export const CleanupJob = table({
name: 'cleanup_job',
scheduled: () => run_cleanup // reducer defined below
}, {
export const CleanupJob = table(
{
name: "cleanup_job",
scheduled: () => run_cleanup, // reducer defined below
},
{
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
targetId: t.u64(), // Your custom data
});
},
);
// 2. Define scheduled reducer (receives full row as arg)
export const run_cleanup = spacetimedb.reducer({ arg: CleanupJob.rowType }, (ctx, { arg }) => {
export const run_cleanup = spacetimedb.reducer(
{ arg: CleanupJob.rowType },
(ctx, { arg }) => {
// arg.scheduledId, arg.targetId available
// Row is auto-deleted after reducer completes
});
},
);
// Schedule a job
import { ScheduleAt } from 'spacetimedb';
import { ScheduleAt } from "spacetimedb";
const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
ctx.db.cleanupJob.insert({
scheduledId: 0n,
scheduledAt: ScheduleAt.time(futureTime),
targetId: someId
targetId: someId,
});
// Cancel a job by deleting the row
@@ -484,8 +533,9 @@ ctx.db.cleanupJob.scheduledId.delete(jobId);
## 6) Timestamps
### Server-side
```typescript
import { Timestamp, ScheduleAt } from 'spacetimedb';
import { Timestamp, ScheduleAt } from "spacetimedb";
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
@@ -495,7 +545,9 @@ const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
```
### Client-side (CRITICAL)
**Timestamps are objects, not numbers:**
```typescript
// ❌ WRONG
const date = new Date(row.createdAt);
@@ -506,9 +558,10 @@ const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
### ScheduleAt on client
```typescript
// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
if (scheduleAt.tag === "Time") {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
```
@@ -520,31 +573,36 @@ if (scheduleAt.tag === 'Time') {
**`public: true` exposes ALL rows to ALL clients.**
| Scenario | Pattern |
|----------|---------|
| ------------------------- | ------------------------------------- |
| Everyone sees all rows | `public: true` |
| Users see only their data | Private table + filtered subscription |
### Subscription patterns (client-side)
```typescript
// Subscribe to ALL public tables (simplest)
conn.subscriptionBuilder().subscribeToAll();
// Subscribe to specific tables with SQL
conn.subscriptionBuilder().subscribe([
'SELECT * FROM message',
'SELECT * FROM room WHERE is_public = true',
conn
.subscriptionBuilder()
.subscribe([
"SELECT * FROM message",
"SELECT * FROM room WHERE is_public = true",
]);
// Handle subscription lifecycle
conn.subscriptionBuilder()
.onApplied(() => console.log('Initial data loaded'))
.onError((e) => console.error('Subscription failed:', e))
conn
.subscriptionBuilder()
.onApplied(() => console.log("Initial data loaded"))
.onError((e) => console.error("Subscription failed:", e))
.subscribeToAll();
```
### Private table + view pattern (RECOMMENDED)
**Views are the recommended approach** for controlling data visibility. They provide:
- Server-side filtering (reduces network traffic)
- Real-time updates when underlying data changes
- Full control over what data clients can access
@@ -557,28 +615,29 @@ conn.subscriptionBuilder()
```typescript
// Private table with index on ownerId
export const PrivateData = table(
{ name: 'private_data',
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
{
name: "private_data",
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
},
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
secret: t.string()
}
secret: t.string(),
},
);
// ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change)
spacetimedb.view(
{ name: 'my_data_slow', public: true },
{ name: "my_data_slow", public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.iter()] // Works but VERY slow at scale
(ctx) => [...ctx.db.privateData.iter()], // Works but VERY slow at scale
);
// ✅ GOOD — index lookup enables targeted invalidation
spacetimedb.view(
{ name: 'my_data', public: true },
{ name: "my_data", public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)]
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)],
);
```
@@ -588,32 +647,40 @@ spacetimedb.view(
// Query-builder views return a query; the SQL engine maintains the result incrementally.
// This can scan the whole table if needed (e.g. leaderboard-style queries).
spacetimedb.anonymousView(
{ name: 'top_players', public: true },
{ name: "top_players", public: true },
t.array(Player.rowType),
(ctx) =>
ctx.from.player
.where(p => p.score.gt(1000))
(ctx) => ctx.from.player.where((p) => p.score.gt(1000)),
);
```
### ViewContext vs AnonymousViewContext
```typescript
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
spacetimedb.view(
{ name: "my_items", public: true },
t.array(Item.rowType),
(ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
},
);
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
spacetimedb.anonymousView(
{ name: "leaderboard", public: true },
t.array(LeaderboardRow),
(ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});
},
);
```
**Views require explicit subscription:**
```typescript
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
"SELECT * FROM public_table",
"SELECT * FROM my_data", // Views need explicit SQL!
]);
```
@@ -622,16 +689,18 @@ conn.subscriptionBuilder().subscribe([
## 8) React Integration
### Key patterns
```typescript
// Memoize connectionBuilder to prevent reconnects on re-render
const builder = useMemo(() =>
const builder = useMemo(
() =>
DbConnection.builder()
.withUri(SPACETIMEDB_URI)
.withDatabaseName(MODULE_NAME)
.withToken(localStorage.getItem('auth_token') || undefined)
.withToken(localStorage.getItem("auth_token") || undefined)
.onConnect(onConnect)
.onConnectError(onConnectError),
[] // Empty deps - only create once
[], // Empty deps - only create once
);
// useTable returns tuple [rows, isLoading]
@@ -650,6 +719,7 @@ const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();
⚠️ Procedures are currently in beta. API may change.
### Defining a procedure
**Procedure name comes from the export — NOT from a string argument.** Use `procedure(params, ret, fn)` or `procedure(ret, fn)`.
```typescript
@@ -660,7 +730,7 @@ export const fetch_external_data = spacetimedb.procedure(
(ctx, { url }) => {
const response = ctx.http.fetch(url);
return response.text();
}
},
);
```
@@ -692,8 +762,9 @@ spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
```
### Key differences from reducers
| Reducers | Procedures |
|----------|------------|
| --------------------------- | ------------------------------------- |
| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` |
| Automatic transaction | Manual transaction management |
| No HTTP/network | `ctx.http.fetch()` available |
@@ -704,6 +775,7 @@ spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
## 10) Project Structure
### Server (`backend/spacetimedb/`)
```
src/schema.ts → Tables, export spacetimedb
src/index.ts → Reducers, lifecycle, import schema
@@ -712,12 +784,14 @@ tsconfig.json → Standard config
```
### Avoiding circular imports
```
schema.ts → defines tables AND exports spacetimedb
index.ts → imports spacetimedb from ./schema, defines reducers
```
### Client (`client/`)
```
src/module_bindings/ → Generated (spacetime generate)
src/main.tsx → Provider, connection setup
+155 -81
View File
@@ -9,7 +9,7 @@
## Language-Specific Rules
| Language | Rule File |
|----------|-----------|
| ----------------------- | ---------------------------------------- |
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
@@ -44,6 +44,7 @@ When implementing a feature that spans backend and client:
## Index System
SpacetimeDB automatically creates indexes for:
- Primary key columns
- Columns marked as unique
@@ -52,6 +53,7 @@ You can add explicit indexes on non-unique columns for query performance.
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
**Schema ↔ Code coupling:**
- Your query code references indexes by name
- If you add/remove/rename an index in the schema, update all code that uses it
- Removing an index without updating queries causes runtime errors
@@ -85,7 +87,7 @@ spacetime logs <db-name>
## Deployment
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
- The default server marked by *** in `spacetime server list` should be used when publishing
- The default server marked by \*\*\* in `spacetime server list` should be used when publishing
- If the default server is maincloud you should publish to maincloud
- Publishing to maincloud is free of charge
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
@@ -110,7 +112,6 @@ spacetime logs <db-name>
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
# SpacetimeDB TypeScript SDK
## ⛔ HALLUCINATED APIs — DO NOT USE
@@ -139,11 +140,11 @@ tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object!
```typescript
// ✅ CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
import { DbConnection, tables } from "./module_bindings"; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from "spacetimedb/react";
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.doSomething({ value: "test" });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
@@ -151,6 +152,7 @@ const [items, isLoading] = useTable(tables.item);
```
### ⛔ DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
@@ -161,7 +163,7 @@ const [items, isLoading] = useTable(tables.item);
### Server-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| ------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------ |
| Missing `package.json` | Create `package.json` | "could not detect language" |
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
@@ -187,7 +189,7 @@ const [items, isLoading] = useTable(tables.item);
### Client-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| ------------------------------------- | ------------------------------------------- | ----------------------- |
| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
@@ -202,62 +204,77 @@ const [items, isLoading] = useTable(tables.item);
**`table()` takes TWO arguments: `table(OPTIONS, COLUMNS)`**
```typescript
import { schema, table, t } from 'spacetimedb/server';
import { schema, table, t } from "spacetimedb/server";
// ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error
export const Task = table({ name: 'task' }, {
export const Task = table(
{ name: "task" },
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] // ❌ WRONG!
});
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }], // ❌ WRONG!
},
);
// ✅ RIGHT — indexes in OPTIONS (first argument)
export const Task = table({
name: 'task',
export const Task = table(
{
name: "task",
public: true,
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
}, {
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
},
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
title: t.string(),
createdAt: t.timestamp(),
});
},
);
```
### Column types
```typescript
t.identity() // User identity (primary key for per-user tables)
t.u64() // Unsigned 64-bit integer (use for IDs)
t.string() // Text
t.bool() // Boolean
t.timestamp() // Timestamp (use ctx.timestamp for current time)
t.scheduleAt() // For scheduled tables only
t.identity(); // User identity (primary key for per-user tables)
t.u64(); // Unsigned 64-bit integer (use for IDs)
t.string(); // Text
t.bool(); // Boolean
t.timestamp(); // Timestamp (use ctx.timestamp for current time)
t.scheduleAt(); // For scheduled tables only
// Product types (nested objects) — use t.object, NOT t.struct
const Point = t.object('Point', { x: t.i32(), y: t.i32() });
const Point = t.object("Point", { x: t.i32(), y: t.i32() });
// Sum types (tagged unions) — use t.enum, NOT t.sum
const Shape = t.enum('Shape', { circle: t.i32(), rectangle: Point });
const Shape = t.enum("Shape", { circle: t.i32(), rectangle: Point });
// Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } }
// Modifiers
t.string().optional() // Nullable
t.u64().primaryKey() // Primary key
t.u64().primaryKey().autoInc() // Auto-increment primary key
t.string().optional(); // Nullable
t.u64().primaryKey(); // Primary key
t.u64().primaryKey().autoInc(); // Auto-increment primary key
```
> ⚠️ **BIGINT SYNTAX:** All `u64`, `i64`, and ID fields use JavaScript BigInt.
>
> - Literals: `0n`, `1n`, `100n` (NOT `0`, `1`, `100`)
> - Comparisons: `row.id === 5n` (NOT `row.id === 5`)
> - Arithmetic: `row.count + 1n` (NOT `row.count + 1`)
### Auto-increment placeholder
```typescript
// ✅ MUST provide 0n placeholder for auto-inc fields
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title: 'New', createdAt: ctx.timestamp });
ctx.db.task.insert({
id: 0n,
ownerId: ctx.sender,
title: "New",
createdAt: ctx.timestamp,
});
```
### Insert returns ROW, not ID
```typescript
// ❌ WRONG
const id = ctx.db.task.insert({ ... });
@@ -268,6 +285,7 @@ const newId = row.id; // Extract .id from returned row
```
### Schema export (CRITICAL)
```typescript
// At end of schema.ts — schema() takes exactly ONE argument: an object
const spacetimedb = schema({ table1, table2, table3 });
@@ -294,7 +312,9 @@ const msgs = [...ctx.db.message.message_room_id.filter(roomId)];
// 3. NO INDEX — use .iter() + manual filter
for (const m of ctx.db.roomMember.iter()) {
if (m.roomId === roomId) { /* ... */ }
if (m.roomId === roomId) {
/* ... */
}
}
```
@@ -302,24 +322,31 @@ for (const m of ctx.db.roomMember.iter()) {
```typescript
// In table OPTIONS (first argument), not columns
export const Message = table({
name: 'message',
export const Message = table(
{
name: "message",
public: true,
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
}, {
indexes: [
{ name: "message_room_id", algorithm: "btree", columns: ["roomId"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
roomId: t.u64(),
// ...
});
},
);
```
### Naming conventions
**Table names — automatic transformation:**
- Schema: `table({ name: 'my_messages' })`
- Access: `ctx.db.myMessages` (automatic snake_case → camelCase)
**Index names — NO transformation, use EXACTLY as defined:**
```typescript
// Schema definition
indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }]
@@ -335,6 +362,7 @@ ctx.db.canvasMember.canvas_member_canvas_id.filter(...)
> ⚠️ **Index names are used VERBATIM** — pick a convention (snake_case or camelCase) and stick with it.
**Index naming pattern — use `{tableName}_{columnName}`:**
```typescript
// ✅ GOOD — unique names across entire module
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
@@ -346,10 +374,12 @@ indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT!
```
**Client-side table names:**
- Check generated `module_bindings/index.ts` for exact export names
- Usage: `useTable(tables.MyMessages)` or `tables.myMessages` (varies by SDK version)
### Filter vs Find
```typescript
// Filter takes VALUE directly, not object — returns iterator
const rows = [...ctx.db.task.by_owner.filter(ownerId)];
@@ -359,13 +389,16 @@ const row = ctx.db.player.identity.find(ctx.sender);
```
### ⚠️ Multi-column indexes are BROKEN
```typescript
// ❌ DON'T — causes PANIC
ctx.db.scores.by_player_level.filter(playerId);
// ✅ DO — use single-column index + manual filter
for (const row of ctx.db.scores.by_player.filter(playerId)) {
if (row.level === targetLevel) { /* ... */ }
if (row.level === targetLevel) {
/* ... */
}
}
```
@@ -374,6 +407,7 @@ for (const row of ctx.db.scores.by_player.filter(playerId)) {
## 4) Reducers
### Definition syntax (CRITICAL)
**Reducer name comes from the export — NOT from a string argument.** Use `reducer(params, fn)` or `reducer(fn)`.
```typescript
@@ -403,17 +437,23 @@ spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) =>
```
### Update pattern (CRITICAL)
```typescript
// ✅ CORRECT — spread existing row, override specific fields
const existing = ctx.db.task.id.find(taskId);
if (!existing) throw new SenderError('Task not found');
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
if (!existing) throw new SenderError("Task not found");
ctx.db.task.id.update({
...existing,
title: newTitle,
updatedAt: ctx.timestamp,
});
// ❌ WRONG — partial update nulls out other fields!
ctx.db.task.id.update({ id: taskId, title: newTitle });
```
### Delete pattern
```typescript
// Delete by primary key VALUE (not row object)
ctx.db.task.id.delete(taskId); // taskId is the u64 value
@@ -421,6 +461,7 @@ ctx.db.player.identity.delete(ctx.sender); // delete by identity
```
### Lifecycle hooks
```typescript
spacetimedb.clientConnected((ctx) => {
// ctx.sender is the connecting identity
@@ -433,16 +474,18 @@ spacetimedb.clientDisconnected((ctx) => {
```
### Snake_case to camelCase conversion
- Server: `export const do_something = spacetimedb.reducer(...)` — name from export
- Client: `conn.reducers.doSomething({ ... })`
### Object syntax required
```typescript
// ❌ WRONG - positional
conn.reducers.doSomething('value');
conn.reducers.doSomething("value");
// ✅ RIGHT - object
conn.reducers.doSomething({ param: 'value' });
conn.reducers.doSomething({ param: "value" });
```
---
@@ -451,28 +494,34 @@ conn.reducers.doSomething({ param: 'value' });
```typescript
// 1. Define table first (scheduled: () => reducer — pass the exported reducer)
export const CleanupJob = table({
name: 'cleanup_job',
scheduled: () => run_cleanup // reducer defined below
}, {
export const CleanupJob = table(
{
name: "cleanup_job",
scheduled: () => run_cleanup, // reducer defined below
},
{
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
targetId: t.u64(), // Your custom data
});
},
);
// 2. Define scheduled reducer (receives full row as arg)
export const run_cleanup = spacetimedb.reducer({ arg: CleanupJob.rowType }, (ctx, { arg }) => {
export const run_cleanup = spacetimedb.reducer(
{ arg: CleanupJob.rowType },
(ctx, { arg }) => {
// arg.scheduledId, arg.targetId available
// Row is auto-deleted after reducer completes
});
},
);
// Schedule a job
import { ScheduleAt } from 'spacetimedb';
import { ScheduleAt } from "spacetimedb";
const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
ctx.db.cleanupJob.insert({
scheduledId: 0n,
scheduledAt: ScheduleAt.time(futureTime),
targetId: someId
targetId: someId,
});
// Cancel a job by deleting the row
@@ -484,8 +533,9 @@ ctx.db.cleanupJob.scheduledId.delete(jobId);
## 6) Timestamps
### Server-side
```typescript
import { Timestamp, ScheduleAt } from 'spacetimedb';
import { Timestamp, ScheduleAt } from "spacetimedb";
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
@@ -495,7 +545,9 @@ const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
```
### Client-side (CRITICAL)
**Timestamps are objects, not numbers:**
```typescript
// ❌ WRONG
const date = new Date(row.createdAt);
@@ -506,9 +558,10 @@ const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
### ScheduleAt on client
```typescript
// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
if (scheduleAt.tag === "Time") {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}
```
@@ -520,31 +573,36 @@ if (scheduleAt.tag === 'Time') {
**`public: true` exposes ALL rows to ALL clients.**
| Scenario | Pattern |
|----------|---------|
| ------------------------- | ------------------------------------- |
| Everyone sees all rows | `public: true` |
| Users see only their data | Private table + filtered subscription |
### Subscription patterns (client-side)
```typescript
// Subscribe to ALL public tables (simplest)
conn.subscriptionBuilder().subscribeToAll();
// Subscribe to specific tables with SQL
conn.subscriptionBuilder().subscribe([
'SELECT * FROM message',
'SELECT * FROM room WHERE is_public = true',
conn
.subscriptionBuilder()
.subscribe([
"SELECT * FROM message",
"SELECT * FROM room WHERE is_public = true",
]);
// Handle subscription lifecycle
conn.subscriptionBuilder()
.onApplied(() => console.log('Initial data loaded'))
.onError((e) => console.error('Subscription failed:', e))
conn
.subscriptionBuilder()
.onApplied(() => console.log("Initial data loaded"))
.onError((e) => console.error("Subscription failed:", e))
.subscribeToAll();
```
### Private table + view pattern (RECOMMENDED)
**Views are the recommended approach** for controlling data visibility. They provide:
- Server-side filtering (reduces network traffic)
- Real-time updates when underlying data changes
- Full control over what data clients can access
@@ -557,28 +615,29 @@ conn.subscriptionBuilder()
```typescript
// Private table with index on ownerId
export const PrivateData = table(
{ name: 'private_data',
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
{
name: "private_data",
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
},
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
secret: t.string()
}
secret: t.string(),
},
);
// ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change)
spacetimedb.view(
{ name: 'my_data_slow', public: true },
{ name: "my_data_slow", public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.iter()] // Works but VERY slow at scale
(ctx) => [...ctx.db.privateData.iter()], // Works but VERY slow at scale
);
// ✅ GOOD — index lookup enables targeted invalidation
spacetimedb.view(
{ name: 'my_data', public: true },
{ name: "my_data", public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)]
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)],
);
```
@@ -588,32 +647,40 @@ spacetimedb.view(
// Query-builder views return a query; the SQL engine maintains the result incrementally.
// This can scan the whole table if needed (e.g. leaderboard-style queries).
spacetimedb.anonymousView(
{ name: 'top_players', public: true },
{ name: "top_players", public: true },
t.array(Player.rowType),
(ctx) =>
ctx.from.player
.where(p => p.score.gt(1000))
(ctx) => ctx.from.player.where((p) => p.score.gt(1000)),
);
```
### ViewContext vs AnonymousViewContext
```typescript
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
spacetimedb.view(
{ name: "my_items", public: true },
t.array(Item.rowType),
(ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
},
);
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
spacetimedb.anonymousView(
{ name: "leaderboard", public: true },
t.array(LeaderboardRow),
(ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});
},
);
```
**Views require explicit subscription:**
```typescript
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
"SELECT * FROM public_table",
"SELECT * FROM my_data", // Views need explicit SQL!
]);
```
@@ -622,16 +689,18 @@ conn.subscriptionBuilder().subscribe([
## 8) React Integration
### Key patterns
```typescript
// Memoize connectionBuilder to prevent reconnects on re-render
const builder = useMemo(() =>
const builder = useMemo(
() =>
DbConnection.builder()
.withUri(SPACETIMEDB_URI)
.withDatabaseName(MODULE_NAME)
.withToken(localStorage.getItem('auth_token') || undefined)
.withToken(localStorage.getItem("auth_token") || undefined)
.onConnect(onConnect)
.onConnectError(onConnectError),
[] // Empty deps - only create once
[], // Empty deps - only create once
);
// useTable returns tuple [rows, isLoading]
@@ -650,6 +719,7 @@ const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();
⚠️ Procedures are currently in beta. API may change.
### Defining a procedure
**Procedure name comes from the export — NOT from a string argument.** Use `procedure(params, ret, fn)` or `procedure(ret, fn)`.
```typescript
@@ -660,7 +730,7 @@ export const fetch_external_data = spacetimedb.procedure(
(ctx, { url }) => {
const response = ctx.http.fetch(url);
return response.text();
}
},
);
```
@@ -692,8 +762,9 @@ spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
```
### Key differences from reducers
| Reducers | Procedures |
|----------|------------|
| --------------------------- | ------------------------------------- |
| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` |
| Automatic transaction | Manual transaction management |
| No HTTP/network | `ctx.http.fetch()` available |
@@ -704,6 +775,7 @@ spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
## 10) Project Structure
### Server (`backend/spacetimedb/`)
```
src/schema.ts → Tables, export spacetimedb
src/index.ts → Reducers, lifecycle, import schema
@@ -712,12 +784,14 @@ tsconfig.json → Standard config
```
### Avoiding circular imports
```
schema.ts → defines tables AND exports spacetimedb
index.ts → imports spacetimedb from ./schema, defines reducers
```
### Client (`client/`)
```
src/module_bindings/ → Generated (spacetime generate)
src/main.tsx → Provider, connection setup
+4 -4
View File
@@ -35,7 +35,7 @@ export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
},
@@ -48,11 +48,11 @@ export default tseslint.config({
```js
// eslint.config.js
import react from 'eslint-plugin-react';
import react from "eslint-plugin-react";
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
settings: { react: { version: "18.3" } },
plugins: {
// Add the react plugin
react,
@@ -61,7 +61,7 @@ export default tseslint.config({
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...react.configs["jsx-runtime"].rules,
},
});
```
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/assets/index-BTS-ufhw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DbhXHqjh.css">
<link rel="stylesheet" crossorigin href="/assets/index-DbhXHqjh.css" />
</head>
<body>
<div id="root"></div>
+2558 -1241
View File
File diff suppressed because it is too large Load Diff
+50 -23
View File
@@ -1,11 +1,10 @@
lockfileVersion: '9.0'
lockfileVersion: "9.0"
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
spacetimedb:
@@ -17,42 +16,62 @@ importers:
version: 5.9.3
packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
resolution:
{
integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==,
}
headers-polyfill@4.0.3:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
resolution:
{
integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==,
}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
resolution:
{
integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==,
}
engines: { node: ">= 0.4" }
prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
engines: {node: '>=14'}
resolution:
{
integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==,
}
engines: { node: ">=14" }
hasBin: true
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
resolution:
{
integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==,
}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
resolution:
{
integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==,
}
engines: { node: ">=10" }
spacetimedb@2.1.0:
resolution: {integrity: sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==}
resolution:
{
integrity: sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==,
}
peerDependencies:
'@angular/core': '>=17.0.0'
'@tanstack/react-query': ^5.0.0
"@angular/core": ">=17.0.0"
"@tanstack/react-query": ^5.0.0
react: ^18.0.0 || ^19.0.0-0 || ^19.0.0
svelte: ^4.0.0 || ^5.0.0
undici: ^6.19.2
vue: ^3.3.0
peerDependenciesMeta:
'@angular/core':
"@angular/core":
optional: true
'@tanstack/react-query':
"@tanstack/react-query":
optional: true
react:
optional: true
@@ -64,19 +83,27 @@ packages:
optional: true
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
resolution:
{
integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==,
}
engines: { node: ">= 0.8" }
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
resolution:
{
integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==,
}
engines: { node: ">=14.17" }
hasBin: true
url-polyfill@1.1.14:
resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==}
resolution:
{
integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==,
}
snapshots:
base64-js@1.5.1: {}
headers-polyfill@4.0.3: {}
+416 -163
View File
@@ -1,10 +1,10 @@
import { schema, t, table, SenderError } from 'spacetimedb/server';
import { schema, t, table, SenderError } from "spacetimedb/server";
const channel_kind = t.enum('ChannelKind', { Text: t.unit(), Voice: t.unit() });
const channel_kind = t.enum("ChannelKind", { Text: t.unit(), Voice: t.unit() });
const user = table(
{
name: 'user',
name: "user",
public: true,
},
{
@@ -16,90 +16,95 @@ const user = table(
subject: t.string().optional(),
username: t.string().optional(), // For creds-based auth
password: t.string().optional(), // For creds-based auth (Note: plain text for MVP)
}
},
);
const server = table(
{ name: 'server', public: true },
{ name: "server", public: true },
{
id: t.u64().primaryKey().autoInc(),
name: t.string(),
owner: t.identity().optional(),
}
},
);
const server_member = table(
{
name: 'server_member',
name: "server_member",
public: true,
indexes: [
{ accessor: 'by_identity', algorithm: 'btree', columns: ['identity'] },
{ accessor: 'by_server_id', algorithm: 'btree', columns: ['server_id'] }
]
{ accessor: "by_identity", algorithm: "btree", columns: ["identity"] },
{ accessor: "by_server_id", algorithm: "btree", columns: ["server_id"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
identity: t.identity(),
server_id: t.u64(),
}
},
);
const channel = table(
{
name: 'channel',
name: "channel",
public: true,
indexes: [
{ accessor: 'by_server_id', algorithm: 'btree', columns: ['server_id'] }
]
{ accessor: "by_server_id", algorithm: "btree", columns: ["server_id"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
server_id: t.u64(),
name: t.string(),
kind: channel_kind,
}
},
);
const voice_state = table(
{
name: 'voice_state',
name: "voice_state",
public: true,
indexes: [
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
]
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
identity: t.identity().primaryKey(),
channel_id: t.u64(),
is_sharing_screen: t.bool(),
}
},
);
const watching = table(
{
name: 'watching',
name: "watching",
public: true,
indexes: [
{ accessor: 'by_watcher', algorithm: 'btree', columns: ['watcher'] },
{ accessor: 'by_watchee', algorithm: 'btree', columns: ['watchee'] }
]
{ accessor: "by_watcher", algorithm: "btree", columns: ["watcher"] },
{ accessor: "by_watchee", algorithm: "btree", columns: ["watchee"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
watcher: t.identity(),
watchee: t.identity(),
channel_id: t.u64(),
}
},
);
const sdp_offer = table(
// --- Voice Signaling Tables ---
const voice_sdp_offer = table(
{
name: 'sdp_offer',
name: "voice_sdp_offer",
public: true,
indexes: [
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] },
{ accessor: 'by_sender', algorithm: 'btree', columns: ['sender'] }
]
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
@@ -107,17 +112,17 @@ const sdp_offer = table(
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
}
},
);
const sdp_answer = table(
const voice_sdp_answer = table(
{
name: 'sdp_answer',
name: "voice_sdp_answer",
public: true,
indexes: [
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] },
{ accessor: 'by_sender', algorithm: 'btree', columns: ['sender'] }
]
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
@@ -125,17 +130,17 @@ const sdp_answer = table(
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
}
},
);
const ice_candidate = table(
const voice_ice_candidate = table(
{
name: 'ice_candidate',
name: "voice_ice_candidate",
public: true,
indexes: [
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] },
{ accessor: 'by_sender', algorithm: 'btree', columns: ['sender'] }
]
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
@@ -143,33 +148,96 @@ const ice_candidate = table(
receiver: t.identity(),
candidate: t.string(),
channel_id: t.u64(),
}
},
);
// --- Screen Signaling Tables ---
const screen_sdp_offer = table(
{
name: "screen_sdp_offer",
public: true,
indexes: [
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
},
);
const screen_sdp_answer = table(
{
name: "screen_sdp_answer",
public: true,
indexes: [
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
},
);
const screen_ice_candidate = table(
{
name: "screen_ice_candidate",
public: true,
indexes: [
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
receiver: t.identity(),
candidate: t.string(),
channel_id: t.u64(),
},
);
const thread = table(
{
name: 'thread',
name: "thread",
public: true,
indexes: [
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
]
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
channel_id: t.u64(),
parent_message_id: t.u64().unique(),
name: t.string(),
}
},
);
const message = table(
{
name: 'message',
name: "message",
public: true,
indexes: [
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] },
{ accessor: 'by_thread_id', algorithm: 'btree', columns: ['thread_id'] }
]
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
{ accessor: "by_thread_id", algorithm: "btree", columns: ["thread_id"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
@@ -178,14 +246,30 @@ const message = table(
text: t.string(),
channel_id: t.u64(),
thread_id: t.u64().optional(),
}
},
);
const spacetimedb = schema({ user, server, server_member, channel, voice_state, watching, sdp_offer, sdp_answer, ice_candidate, thread, message });
const spacetimedb = schema({
user,
server,
server_member,
channel,
voice_state,
watching,
voice_sdp_offer,
voice_sdp_answer,
voice_ice_candidate,
screen_sdp_offer,
screen_sdp_answer,
screen_ice_candidate,
thread,
message,
});
export default spacetimedb;
function validateName(name: string) {
if (!name || name.trim().length === 0) throw new SenderError('Names must not be empty');
if (!name || name.trim().length === 0)
throw new SenderError("Names must not be empty");
}
export const set_name = spacetimedb.reducer(
@@ -193,29 +277,31 @@ export const set_name = spacetimedb.reducer(
(ctx, { name }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError('Cannot set name for unknown user');
if (!user) throw new SenderError("Cannot set name for unknown user");
ctx.db.user.identity.update({ ...user, name });
}
},
);
export const set_talking = spacetimedb.reducer(
{ talking: t.bool() },
(ctx, { talking }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError('Cannot set talking status for unknown user');
if (!user)
throw new SenderError("Cannot set talking status for unknown user");
ctx.db.user.identity.update({ ...user, talking });
}
},
);
export const register = spacetimedb.reducer(
{ username: t.string(), password: t.string() },
(ctx, { username, password }) => {
validateName(username);
if (!password || password.length < 4) throw new SenderError('Password must be at least 4 characters');
if (!password || password.length < 4)
throw new SenderError("Password must be at least 4 characters");
for (const u of ctx.db.user.iter()) {
if (u.username === username) {
throw new SenderError('Username already taken');
throw new SenderError("Username already taken");
}
}
@@ -225,7 +311,7 @@ export const register = spacetimedb.reducer(
...user,
username,
password,
name: user.name || username
name: user.name || username,
});
} else {
ctx.db.user.insert({
@@ -236,10 +322,10 @@ export const register = spacetimedb.reducer(
online: true,
talking: false,
issuer: undefined,
subject: undefined
subject: undefined,
});
}
}
},
);
export const login = spacetimedb.reducer(
@@ -254,11 +340,15 @@ export const login = spacetimedb.reducer(
}
if (!foundUser) {
throw new SenderError('Invalid username or password');
throw new SenderError("Invalid username or password");
}
const currentIdentityUser = ctx.db.user.identity.find(ctx.sender);
if (currentIdentityUser && currentIdentityUser.identity.toHexString() !== foundUser.identity.toHexString()) {
if (
currentIdentityUser &&
currentIdentityUser.identity.toHexString() !==
foundUser.identity.toHexString()
) {
ctx.db.user.identity.delete(ctx.sender);
}
@@ -267,15 +357,15 @@ export const login = spacetimedb.reducer(
ctx.db.user.insert({
...foundUser,
identity: ctx.sender,
online: true
online: true,
});
} else {
ctx.db.user.identity.update({
...foundUser,
online: true
online: true,
});
}
}
},
);
export const create_server = spacetimedb.reducer(
@@ -284,13 +374,27 @@ export const create_server = spacetimedb.reducer(
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to create a server');
throw new SenderError("You must be logged in to create a server");
}
const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender });
ctx.db.server_member.insert({ id: 0n, identity: ctx.sender, server_id: s.id });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'Text' } });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'Voice' } });
}
ctx.db.server_member.insert({
id: 0n,
identity: ctx.sender,
server_id: s.id,
});
ctx.db.channel.insert({
id: 0n,
server_id: s.id,
name: "general",
kind: { tag: "Text" },
});
ctx.db.channel.insert({
id: 0n,
server_id: s.id,
name: "Voice General",
kind: { tag: "Voice" },
});
},
);
export const join_server = spacetimedb.reducer(
@@ -298,18 +402,22 @@ export const join_server = spacetimedb.reducer(
(ctx, { serverId }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to join a server');
throw new SenderError("You must be logged in to join a server");
}
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError('Server not found');
if (!s) throw new SenderError("Server not found");
// Check if already a member
for (const m of ctx.db.server_member.by_identity.filter(ctx.sender)) {
if (m.server_id === serverId) return;
}
ctx.db.server_member.insert({ id: 0n, identity: ctx.sender, server_id: serverId });
}
ctx.db.server_member.insert({
id: 0n,
identity: ctx.sender,
server_id: serverId,
});
},
);
export const leave_server = spacetimedb.reducer(
@@ -323,7 +431,7 @@ export const leave_server = spacetimedb.reducer(
ctx.db.server_member.id.delete(m.id);
}
}
}
},
);
export const create_channel = spacetimedb.reducer(
@@ -332,17 +440,17 @@ export const create_channel = spacetimedb.reducer(
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to create a channel');
throw new SenderError("You must be logged in to create a channel");
}
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError('Server not found');
if (!s) throw new SenderError("Server not found");
ctx.db.channel.insert({
id: 0n,
server_id: serverId,
name,
kind: isVoice ? { tag: 'Voice' } : { tag: 'Text' }
kind: isVoice ? { tag: "Voice" } : { tag: "Text" },
});
}
},
);
export const join_voice = spacetimedb.reducer(
@@ -350,21 +458,30 @@ export const join_voice = spacetimedb.reducer(
(ctx, { channelId }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to join voice');
throw new SenderError("You must be logged in to join voice");
}
const chan = ctx.db.channel.id.find(channelId);
if (!chan || chan.kind.tag !== 'Voice') throw new SenderError('Invalid voice channel');
if (!chan || chan.kind.tag !== "Voice")
throw new SenderError("Invalid voice channel");
const existing = ctx.db.voice_state.identity.find(ctx.sender);
if (existing) {
if (existing.channel_id !== channelId) {
clearSignalingForUser(ctx, ctx.sender);
ctx.db.voice_state.identity.update({ identity: ctx.sender, channel_id: channelId, is_sharing_screen: false });
ctx.db.voice_state.identity.update({
identity: ctx.sender,
channel_id: channelId,
is_sharing_screen: false,
});
}
} else {
ctx.db.voice_state.insert({ identity: ctx.sender, channel_id: channelId, is_sharing_screen: false });
}
ctx.db.voice_state.insert({
identity: ctx.sender,
channel_id: channelId,
is_sharing_screen: false,
});
}
},
);
export const set_sharing_screen = spacetimedb.reducer(
@@ -372,9 +489,12 @@ export const set_sharing_screen = spacetimedb.reducer(
(ctx, { sharing }) => {
const state = ctx.db.voice_state.identity.find(ctx.sender);
if (state) {
ctx.db.voice_state.identity.update({ ...state, is_sharing_screen: sharing });
}
ctx.db.voice_state.identity.update({
...state,
is_sharing_screen: sharing,
});
}
},
);
export const start_watching = spacetimedb.reducer(
@@ -386,8 +506,13 @@ export const start_watching = spacetimedb.reducer(
for (const w of ctx.db.watching.by_watcher.filter(ctx.sender)) {
if (w.watchee.isEqual(watchee)) return;
}
ctx.db.watching.insert({ id: 0n, watcher: ctx.sender, watchee, channel_id: channelId });
}
ctx.db.watching.insert({
id: 0n,
watcher: ctx.sender,
watchee,
channel_id: channelId,
});
},
);
export const stop_watching = spacetimedb.reducer(
@@ -398,7 +523,7 @@ export const stop_watching = spacetimedb.reducer(
ctx.db.watching.id.delete(w.id);
}
}
}
},
);
export const leave_voice = spacetimedb.reducer((ctx) => {
@@ -409,60 +534,149 @@ export const leave_voice = spacetimedb.reducer((ctx) => {
clearSignalingForUser(ctx, ctx.sender);
});
export const send_sdp_offer = spacetimedb.reducer(
// --- Voice Signaling Reducers ---
export const send_voice_sdp_offer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
// Clear any existing offers/answers/candidates between this pair in both directions
// to ensure a fresh negotiation state.
// Outgoing from sender to receiver
for (const offer of ctx.db.sdp_offer.by_sender.filter(ctx.sender)) {
if (offer.receiver.isEqual(receiver)) ctx.db.sdp_offer.id.delete(offer.id);
for (const row of ctx.db.voice_sdp_offer.by_sender.filter(ctx.sender)) {
if (row.receiver.isEqual(receiver))
ctx.db.voice_sdp_offer.id.delete(row.id);
}
for (const answer of ctx.db.sdp_answer.by_sender.filter(ctx.sender)) {
if (answer.receiver.isEqual(receiver)) ctx.db.sdp_answer.id.delete(answer.id);
for (const row of ctx.db.voice_sdp_answer.by_sender.filter(ctx.sender)) {
if (row.receiver.isEqual(receiver))
ctx.db.voice_sdp_answer.id.delete(row.id);
}
for (const cand of ctx.db.ice_candidate.by_sender.filter(ctx.sender)) {
if (cand.receiver.isEqual(receiver)) ctx.db.ice_candidate.id.delete(cand.id);
for (const row of ctx.db.voice_ice_candidate.by_sender.filter(ctx.sender)) {
if (row.receiver.isEqual(receiver))
ctx.db.voice_ice_candidate.id.delete(row.id);
}
// Incoming to sender from receiver (stale messages from previous negotiations)
for (const offer of ctx.db.sdp_offer.by_receiver.filter(ctx.sender)) {
if (offer.sender.isEqual(receiver)) ctx.db.sdp_offer.id.delete(offer.id);
for (const row of ctx.db.voice_sdp_offer.by_receiver.filter(ctx.sender)) {
if (row.sender.isEqual(receiver))
ctx.db.voice_sdp_offer.id.delete(row.id);
}
for (const answer of ctx.db.sdp_answer.by_receiver.filter(ctx.sender)) {
if (answer.sender.isEqual(receiver)) ctx.db.sdp_answer.id.delete(answer.id);
for (const row of ctx.db.voice_sdp_answer.by_receiver.filter(ctx.sender)) {
if (row.sender.isEqual(receiver))
ctx.db.voice_sdp_answer.id.delete(row.id);
}
for (const cand of ctx.db.ice_candidate.by_receiver.filter(ctx.sender)) {
if (cand.sender.isEqual(receiver)) ctx.db.ice_candidate.id.delete(cand.id);
}
ctx.db.sdp_offer.insert({ id: 0n, sender: ctx.sender, receiver, sdp, channel_id: channelId });
for (const row of ctx.db.voice_ice_candidate.by_receiver.filter(
ctx.sender,
)) {
if (row.sender.isEqual(receiver))
ctx.db.voice_ice_candidate.id.delete(row.id);
}
ctx.db.voice_sdp_offer.insert({
id: 0n,
sender: ctx.sender,
receiver,
sdp,
channel_id: channelId,
});
},
);
export const send_sdp_answer = spacetimedb.reducer(
export const send_voice_sdp_answer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
for (const answer of ctx.db.sdp_answer.by_sender.filter(ctx.sender)) {
if (answer.receiver.isEqual(receiver)) {
ctx.db.sdp_answer.id.delete(answer.id);
}
}
ctx.db.sdp_answer.insert({ id: 0n, sender: ctx.sender, receiver, sdp, channel_id: channelId });
for (const row of ctx.db.voice_sdp_answer.by_sender.filter(ctx.sender)) {
if (row.receiver.isEqual(receiver))
ctx.db.voice_sdp_answer.id.delete(row.id);
}
ctx.db.voice_sdp_answer.insert({
id: 0n,
sender: ctx.sender,
receiver,
sdp,
channel_id: channelId,
});
},
);
export const send_ice_candidate = spacetimedb.reducer(
export const send_voice_ice_candidate = spacetimedb.reducer(
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
(ctx, { receiver, candidate, channelId }) => {
ctx.db.ice_candidate.insert({ id: 0n, sender: ctx.sender, receiver, candidate, channel_id: channelId });
ctx.db.voice_ice_candidate.insert({
id: 0n,
sender: ctx.sender,
receiver,
candidate,
channel_id: channelId,
});
},
);
// --- Screen Signaling Reducers ---
export const send_screen_sdp_offer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
for (const row of ctx.db.screen_sdp_offer.by_sender.filter(ctx.sender)) {
if (row.receiver.isEqual(receiver))
ctx.db.screen_sdp_offer.id.delete(row.id);
}
for (const row of ctx.db.screen_sdp_answer.by_sender.filter(ctx.sender)) {
if (row.receiver.isEqual(receiver))
ctx.db.screen_sdp_answer.id.delete(row.id);
}
for (const row of ctx.db.screen_ice_candidate.by_sender.filter(
ctx.sender,
)) {
if (row.receiver.isEqual(receiver))
ctx.db.screen_ice_candidate.id.delete(row.id);
}
for (const row of ctx.db.screen_sdp_offer.by_receiver.filter(ctx.sender)) {
if (row.sender.isEqual(receiver))
ctx.db.screen_sdp_offer.id.delete(row.id);
}
for (const row of ctx.db.screen_sdp_answer.by_receiver.filter(ctx.sender)) {
if (row.sender.isEqual(receiver))
ctx.db.screen_sdp_answer.id.delete(row.id);
}
for (const row of ctx.db.screen_ice_candidate.by_receiver.filter(
ctx.sender,
)) {
if (row.sender.isEqual(receiver))
ctx.db.screen_ice_candidate.id.delete(row.id);
}
ctx.db.screen_sdp_offer.insert({
id: 0n,
sender: ctx.sender,
receiver,
sdp,
channel_id: channelId,
});
},
);
export const send_screen_sdp_answer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
for (const row of ctx.db.screen_sdp_answer.by_sender.filter(ctx.sender)) {
if (row.receiver.isEqual(receiver))
ctx.db.screen_sdp_answer.id.delete(row.id);
}
ctx.db.screen_sdp_answer.insert({
id: 0n,
sender: ctx.sender,
receiver,
sdp,
channel_id: channelId,
});
},
);
export const send_screen_ice_candidate = spacetimedb.reducer(
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
(ctx, { receiver, candidate, channelId }) => {
ctx.db.screen_ice_candidate.insert({
id: 0n,
sender: ctx.sender,
receiver,
candidate,
channel_id: channelId,
});
},
);
function clearSignalingForUser(ctx: any, identity: any) {
// Clean up stale signaling messages for the user
// Clean up watching status
for (const w of ctx.db.watching.by_watcher.filter(identity)) {
ctx.db.watching.id.delete(w.id);
@@ -471,26 +685,33 @@ function clearSignalingForUser(ctx: any, identity: any) {
ctx.db.watching.id.delete(w.id);
}
for (const offer of ctx.db.sdp_offer.by_sender.filter(identity)) {
ctx.db.sdp_offer.id.delete(offer.id);
}
for (const offer of ctx.db.sdp_offer.by_receiver.filter(identity)) {
ctx.db.sdp_offer.id.delete(offer.id);
}
// Voice Cleanup
for (const row of ctx.db.voice_sdp_offer.by_sender.filter(identity))
ctx.db.voice_sdp_offer.id.delete(row.id);
for (const row of ctx.db.voice_sdp_offer.by_receiver.filter(identity))
ctx.db.voice_sdp_offer.id.delete(row.id);
for (const row of ctx.db.voice_sdp_answer.by_sender.filter(identity))
ctx.db.voice_sdp_answer.id.delete(row.id);
for (const row of ctx.db.voice_sdp_answer.by_receiver.filter(identity))
ctx.db.voice_sdp_answer.id.delete(row.id);
for (const row of ctx.db.voice_ice_candidate.by_sender.filter(identity))
ctx.db.voice_ice_candidate.id.delete(row.id);
for (const row of ctx.db.voice_ice_candidate.by_receiver.filter(identity))
ctx.db.voice_ice_candidate.id.delete(row.id);
for (const answer of ctx.db.sdp_answer.by_sender.filter(identity)) {
ctx.db.sdp_answer.id.delete(answer.id);
}
for (const answer of ctx.db.sdp_answer.by_receiver.filter(identity)) {
ctx.db.sdp_answer.id.delete(answer.id);
}
for (const candidate of ctx.db.ice_candidate.by_sender.filter(identity)) {
ctx.db.ice_candidate.id.delete(candidate.id);
}
for (const candidate of ctx.db.ice_candidate.by_receiver.filter(identity)) {
ctx.db.ice_candidate.id.delete(candidate.id);
}
// Screen Cleanup
for (const row of ctx.db.screen_sdp_offer.by_sender.filter(identity))
ctx.db.screen_sdp_offer.id.delete(row.id);
for (const row of ctx.db.screen_sdp_offer.by_receiver.filter(identity))
ctx.db.screen_sdp_offer.id.delete(row.id);
for (const row of ctx.db.screen_sdp_answer.by_sender.filter(identity))
ctx.db.screen_sdp_answer.id.delete(row.id);
for (const row of ctx.db.screen_sdp_answer.by_receiver.filter(identity))
ctx.db.screen_sdp_answer.id.delete(row.id);
for (const row of ctx.db.screen_ice_candidate.by_sender.filter(identity))
ctx.db.screen_ice_candidate.id.delete(row.id);
for (const row of ctx.db.screen_ice_candidate.by_receiver.filter(identity))
ctx.db.screen_ice_candidate.id.delete(row.id);
}
export const create_thread = spacetimedb.reducer(
@@ -499,23 +720,29 @@ export const create_thread = spacetimedb.reducer(
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to create a thread');
throw new SenderError("You must be logged in to create a thread");
}
const parentMsg = ctx.db.message.id.find(parentMessageId);
if (!parentMsg) throw new SenderError('Parent message not found');
if (!parentMsg) throw new SenderError("Parent message not found");
ctx.db.thread.insert({ id: 0n, channel_id: channelId, parent_message_id: parentMessageId, name });
}
ctx.db.thread.insert({
id: 0n,
channel_id: channelId,
parent_message_id: parentMessageId,
name,
});
},
);
export const send_message = spacetimedb.reducer(
{ text: t.string(), channelId: t.u64(), threadId: t.u64().optional() },
(ctx, { text, channelId, threadId }) => {
if (!text || text.trim().length === 0) throw new SenderError('Messages must not be empty');
if (!text || text.trim().length === 0)
throw new SenderError("Messages must not be empty");
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to send messages');
throw new SenderError("You must be logged in to send messages");
}
ctx.db.message.insert({
@@ -526,23 +753,37 @@ export const send_message = spacetimedb.reducer(
channel_id: channelId,
thread_id: threadId,
});
}
},
);
export const init = spacetimedb.init(ctx => {
export const init = spacetimedb.init((ctx) => {
let hasServers = false;
for (const _ of ctx.db.server.iter()) {
hasServers = true;
break;
}
if (!hasServers) {
const s = ctx.db.server.insert({ id: 0n, name: 'Spacetime Community', owner: undefined });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'Text' } });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'Voice' } });
const s = ctx.db.server.insert({
id: 0n,
name: "Spacetime Community",
owner: undefined,
});
ctx.db.channel.insert({
id: 0n,
server_id: s.id,
name: "general",
kind: { tag: "Text" },
});
ctx.db.channel.insert({
id: 0n,
server_id: s.id,
name: "Voice General",
kind: { tag: "Voice" },
});
}
});
export const onConnect = spacetimedb.clientConnected(ctx => {
export const onConnect = spacetimedb.clientConnected((ctx) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (ctx.senderAuth.hasJWT && ctx.senderAuth.jwt) {
@@ -550,7 +791,11 @@ export const onConnect = spacetimedb.clientConnected(ctx => {
const issuer = jwt.issuer;
const subject = jwt.subject;
const payload = jwt.fullPayload;
const name = (payload.name as string) || (payload.nickname as string) || (payload.preferred_username as string) || (payload.email as string);
const name =
(payload.name as string) ||
(payload.nickname as string) ||
(payload.preferred_username as string) ||
(payload.email as string);
if (user) {
ctx.db.user.identity.update({
@@ -559,7 +804,7 @@ export const onConnect = spacetimedb.clientConnected(ctx => {
talking: false,
name: user.name || name,
issuer,
subject
subject,
});
} else {
ctx.db.user.insert({
@@ -570,7 +815,7 @@ export const onConnect = spacetimedb.clientConnected(ctx => {
issuer,
subject,
username: undefined,
password: undefined
password: undefined,
});
}
} else if (user) {
@@ -585,21 +830,29 @@ export const onConnect = spacetimedb.clientConnected(ctx => {
issuer: undefined,
subject: undefined,
username: undefined,
password: undefined
password: undefined,
});
}
// Auto-join the "Spacetime Community" server if it exists
const communityServer = [...ctx.db.server.iter()].find(s => s.name === 'Spacetime Community');
const communityServer = [...ctx.db.server.iter()].find(
(s) => s.name === "Spacetime Community",
);
if (communityServer) {
const alreadyMember = [...ctx.db.server_member.by_identity.filter(ctx.sender)].some(m => m.server_id === communityServer.id);
const alreadyMember = [
...ctx.db.server_member.by_identity.filter(ctx.sender),
].some((m) => m.server_id === communityServer.id);
if (!alreadyMember) {
ctx.db.server_member.insert({ id: 0n, identity: ctx.sender, server_id: communityServer.id });
ctx.db.server_member.insert({
id: 0n,
identity: ctx.sender,
server_id: communityServer.id,
});
}
}
});
export const onDisconnect = spacetimedb.clientDisconnected(ctx => {
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (user) {
ctx.db.user.identity.update({ ...user, online: false, talking: false });
+25 -15
View File
@@ -20,7 +20,8 @@
body {
margin: 0;
font-family: 'gg sans', 'Noto Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-family:
"gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
background-color: var(--background-tertiary);
color: var(--text-normal);
height: 100vh;
@@ -58,7 +59,9 @@ body {
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-radius 0.2s, background-color 0.2s;
transition:
border-radius 0.2s,
background-color 0.2s;
font-weight: bold;
color: var(--text-normal);
position: relative;
@@ -77,7 +80,7 @@ body {
}
.server-icon.active::before {
content: '';
content: "";
position: absolute;
left: -12px;
width: 8px;
@@ -460,7 +463,6 @@ body {
flex: 1;
}
.video-tile.talking {
border-color: #23a559;
}
@@ -472,7 +474,8 @@ body {
background-color: black;
}
.video-controls, .tile-actions-right {
.video-controls,
.tile-actions-right {
position: absolute;
display: flex;
gap: 8px;
@@ -496,7 +499,8 @@ body {
opacity: 1;
}
.watch-btn, .mute-tile-btn {
.watch-btn,
.mute-tile-btn {
background-color: var(--brand);
color: white;
border: none;
@@ -509,15 +513,16 @@ body {
justify-content: center;
}
.watch-btn.active, .mute-tile-btn {
.watch-btn.active,
.mute-tile-btn {
background-color: rgba(30, 31, 34, 0.7);
}
.watch-btn.active:hover, .mute-tile-btn:hover {
.watch-btn.active:hover,
.mute-tile-btn:hover {
background-color: rgba(43, 45, 49, 0.9);
}
.fullscreen-btn {
background-color: rgba(0, 0, 0, 0.5);
color: white;
@@ -636,12 +641,12 @@ body {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24);
z-index: 2000;
color: var(--text-normal, #dbdee1);
font-family: 'gg sans', sans-serif;
font-family: "gg sans", sans-serif;
pointer-events: none;
}
.connection-popover::before {
content: '';
content: "";
position: absolute;
top: -6px;
left: 20px;
@@ -671,9 +676,15 @@ body {
font-weight: 800;
}
.popover-status.green { color: #23a559; }
.popover-status.yellow { color: #f0b232; }
.popover-status.red { color: #f23f43; }
.popover-status.green {
color: #23a559;
}
.popover-status.yellow {
color: #f0b232;
}
.popover-status.red {
color: #f23f43;
}
.popover-info {
font-size: 0.8rem;
@@ -709,7 +720,6 @@ body {
}
.member-name {
...
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
+25 -25
View File
@@ -1,24 +1,24 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import App from './App';
import { SpacetimeDBProvider } from 'spacetimedb/react';
import { DbConnection } from './module_bindings';
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import App from "./App";
import { SpacetimeDBProvider } from "spacetimedb/react";
import { DbConnection } from "./module_bindings";
describe('App Integration Test', () => {
it('connects to the DB, allows name change and message sending', async () => {
describe("App Integration Test", () => {
it("connects to the DB, allows name change and message sending", async () => {
const connectionBuilder = DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('quickstart-chat')
.withUri("ws://localhost:3000")
.withDatabaseName("quickstart-chat")
.withToken(
localStorage.getItem(
'ws://localhost:3000/quickstart-chat/auth_token'
) || ''
"ws://localhost:3000/quickstart-chat/auth_token",
) || "",
);
render(
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
<App />
</SpacetimeDBProvider>
</SpacetimeDBProvider>,
);
// Initially, we should see "Connecting..."
@@ -29,7 +29,7 @@ describe('App Integration Test', () => {
await waitFor(
() =>
expect(screen.queryByText(/Connecting.../i)).not.toBeInTheDocument(),
{ timeout: 10000 }
{ timeout: 10000 },
);
// The profile section should show the default name or truncated identity
@@ -37,40 +37,40 @@ describe('App Integration Test', () => {
// If your default identity is something like 'abcdef12' or 'Unknown'
// we do a generic check:
expect(
screen.getByRole('heading', { name: /profile/i })
screen.getByRole("heading", { name: /profile/i }),
).toBeInTheDocument();
// Let's change the user's name
const editNameButton = screen.getByText(/Edit Name/i);
await userEvent.click(editNameButton);
const nameInput = screen.getByRole('textbox', { name: /name input/i });
const nameInput = screen.getByRole("textbox", { name: /name input/i });
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'TestUser');
const submitNameButton = screen.getByRole('button', { name: /submit/i });
await userEvent.type(nameInput, "TestUser");
const submitNameButton = screen.getByRole("button", { name: /submit/i });
await userEvent.click(submitNameButton);
// If your DB or UI updates instantly, we can check that the new name shows up
await waitFor(
() => {
expect(screen.getByText('TestUser')).toBeInTheDocument();
expect(screen.getByText("TestUser")).toBeInTheDocument();
},
{ timeout: 10000 }
{ timeout: 10000 },
);
// Now let's send a message
const textarea = screen.getByRole('textbox', { name: /message input/i });
await userEvent.type(textarea, 'Hello from GH Actions!');
const textarea = screen.getByRole("textbox", { name: /message input/i });
await userEvent.type(textarea, "Hello from GH Actions!");
const sendButton = screen.getByRole('button', { name: /send/i });
const sendButton = screen.getByRole("button", { name: /send/i });
await userEvent.click(sendButton);
// Wait for message to appear in the UI
await waitFor(
() => {
expect(screen.getByText('Hello from GH Actions!')).toBeInTheDocument();
expect(screen.getByText("Hello from GH Actions!")).toBeInTheDocument();
},
{ timeout: 10000 }
{ timeout: 10000 },
);
});
});
+4 -6
View File
@@ -1,5 +1,5 @@
import React from 'react';
import './App.css';
import React from "react";
import "./App.css";
// Remove all imports related to SpacetimeDB, auth, and chat logic that are now in ChatContainer or other modules
// import { tables, reducers } from './module_bindings';
// import type * as Types from './module_bindings/types';
@@ -9,16 +9,14 @@ import './App.css';
// import { TOKEN_KEY } from './main';
// Import the new ChatContainer component
import { ChatContainer } from './chat'; // Import from index.ts
import { ChatContainer } from "./chat"; // Import from index.ts
function App() {
// All the state, effects, reducers, table fetches, and UI rendering logic
// related to chat and authentication have been moved to ChatContainer and its sub-components.
// App.tsx now simply renders the ChatContainer.
return (
<ChatContainer />
);
return <ChatContainer />;
}
export default App;
+19 -13
View File
@@ -1,9 +1,9 @@
// src/auth/AuthGate.tsx
import React, { useState, useContext, useEffect } from 'react';
import { useAuth } from 'react-oidc-context';
import UsernamePasswordAuth from './UsernamePasswordAuth';
import App from '../App';
import { TOKEN_KEY } from '../main.tsx';
import React, { useState, useContext, useEffect } from "react";
import { useAuth } from "react-oidc-context";
import UsernamePasswordAuth from "./UsernamePasswordAuth";
import App from "../App";
import { TOKEN_KEY } from "../main.tsx";
interface AuthGateProps {
children: React.ReactNode; // This will be SpacetimeDBWrapper
@@ -12,12 +12,14 @@ interface AuthGateProps {
function AuthGate({ children }: AuthGateProps) {
const auth = useAuth();
const [authError, setAuthError] = useState<string | null>(null);
const [hasStoredToken, setHasStoredToken] = useState(!!localStorage.getItem(TOKEN_KEY));
const [hasStoredToken, setHasStoredToken] = useState(
!!localStorage.getItem(TOKEN_KEY),
);
// Logging authentication state
console.log('AuthGate: auth.isLoading:', auth.isLoading);
console.log('AuthGate: auth.isAuthenticated:', auth.isAuthenticated);
console.log('AuthGate: hasStoredToken:', hasStoredToken);
console.log("AuthGate: auth.isLoading:", auth.isLoading);
console.log("AuthGate: auth.isAuthenticated:", auth.isAuthenticated);
console.log("AuthGate: hasStoredToken:", hasStoredToken);
const handleUsernamePasswordLoginSuccess = () => {
console.log("Username/Password login successful. AuthGate will re-render.");
@@ -25,7 +27,9 @@ function AuthGate({ children }: AuthGateProps) {
};
const handleUsernamePasswordRegisterSuccess = () => {
console.log("Username/Password registration successful. AuthGate will re-render.");
console.log(
"Username/Password registration successful. AuthGate will re-render.",
);
setHasStoredToken(true);
};
@@ -57,15 +61,17 @@ function AuthGate({ children }: AuthGateProps) {
<div className="login-card">
<h1>Welcome!</h1>
<p>Please log in to continue to the chat.</p>
{authError && <p style={{ color: '#da373c', marginBottom: '10px' }}>{authError}</p>}
{authError && (
<p style={{ color: "#da373c", marginBottom: "10px" }}>{authError}</p>
)}
<button
onClick={() => auth.signinRedirect()}
disabled={auth.isLoading}
className="btn-primary"
style={{ width: '100%', marginBottom: '10px' }}
style={{ width: "100%", marginBottom: "10px" }}
>
{auth.isLoading ? 'Loading...' : 'Login with OIDC'}
{auth.isLoading ? "Loading..." : "Login with OIDC"}
</button>
<UsernamePasswordAuth
+6 -9
View File
@@ -1,17 +1,18 @@
// src/auth/OidcProvider.tsx
import { ReactNode } from 'react';
import { AuthProvider } from 'react-oidc-context';
import { ReactNode } from "react";
import { AuthProvider } from "react-oidc-context";
// OIDC Configuration - User should replace these with their own provider values
export const oidcConfig = {
authority: import.meta.env.VITE_OIDC_AUTHORITY ?? "https://accounts.google.com",
authority:
import.meta.env.VITE_OIDC_AUTHORITY ?? "https://accounts.google.com",
client_id: import.meta.env.VITE_OIDC_CLIENT_ID ?? "REPLACE_ME",
redirect_uri: window.location.origin,
scope: "openid profile email",
response_type: "code",
onSigninCallback: () => {
window.history.replaceState({}, document.title, window.location.pathname);
}
},
};
interface OidcProviderProps {
@@ -19,9 +20,5 @@ interface OidcProviderProps {
}
export function OidcProvider({ children }: OidcProviderProps) {
return (
<AuthProvider {...oidcConfig}>
{children}
</AuthProvider>
);
return <AuthProvider {...oidcConfig}>{children}</AuthProvider>;
}
+63 -25
View File
@@ -1,8 +1,8 @@
// src/auth/UsernamePasswordAuth.tsx
import React, { useState, useEffect, useContext } from 'react';
import { Identity } from 'spacetimedb';
import { useSpacetimeDB } from 'spacetimedb/react'; // Correct hook for SpacetimeDB connection
import { TOKEN_KEY } from '../main.tsx'; // Import the token key
import React, { useState, useEffect, useContext } from "react";
import { Identity } from "spacetimedb";
import { useSpacetimeDB } from "spacetimedb/react"; // Correct hook for SpacetimeDB connection
import { TOKEN_KEY } from "../main.tsx"; // Import the token key
// Define the expected shape of the DbConnection instance from the hook
interface SpacetimeDBConnection {
@@ -16,9 +16,13 @@ interface UsernamePasswordAuthProps {
onError: (error: string | null) => void; // Callback for errors
}
function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: UsernamePasswordAuthProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
function UsernamePasswordAuth({
onLoginSuccess,
onRegisterSuccess,
onError,
}: UsernamePasswordAuthProps) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Get the SpacetimeDB connection instance using the correct hook
@@ -26,11 +30,11 @@ function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: Us
const handleLogin = async () => {
if (!conn) {
onError('Database connection not available.');
onError("Database connection not available.");
return;
}
if (!username || !password) {
onError('Please enter both username and password.');
onError("Please enter both username and password.");
return;
}
setIsLoading(true);
@@ -45,8 +49,8 @@ function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: Us
// The actual state update will come through the subscription.
onLoginSuccess();
} catch (e: any) {
onError(`Login error: ${e.message || 'Unknown error'}`);
console.error('Login error:', e);
onError(`Login error: ${e.message || "Unknown error"}`);
console.error("Login error:", e);
} finally {
setIsLoading(false);
}
@@ -54,11 +58,11 @@ function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: Us
const handleRegister = async () => {
if (!conn) {
onError('Database connection not available.');
onError("Database connection not available.");
return;
}
if (!username || !password) {
onError('Please enter both username and password.');
onError("Please enter both username and password.");
return;
}
setIsLoading(true);
@@ -67,41 +71,75 @@ function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: Us
conn.reducers.register({ username, password });
onRegisterSuccess();
} catch (e: any) {
onError(`Registration error: ${e.message || 'Unknown error'}`);
console.error('Registration error:', e);
onError(`Registration error: ${e.message || "Unknown error"}`);
console.error("Registration error:", e);
} finally {
setIsLoading(false);
}
};
return (
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px', marginBottom: '20px' }}>
<h3 style={{ marginTop: '0' }}>Username/Password Authentication</h3>
<div style={{ marginBottom: '10px' }}>
<div
style={{
padding: "20px",
border: "1px solid #ccc",
borderRadius: "8px",
marginBottom: "20px",
}}
>
<h3 style={{ marginTop: "0" }}>Username/Password Authentication</h3>
<div style={{ marginBottom: "10px" }}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
style={{ padding: '8px', marginRight: '10px', borderRadius: '4px', border: '1px solid #ccc' }}
style={{
padding: "8px",
marginRight: "10px",
borderRadius: "4px",
border: "1px solid #ccc",
}}
disabled={isLoading}
/>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ marginBottom: "10px" }}>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ padding: '8px', marginRight: '10px', borderRadius: '4px', border: '1px solid #ccc' }}
style={{
padding: "8px",
marginRight: "10px",
borderRadius: "4px",
border: "1px solid #ccc",
}}
disabled={isLoading}
/>
</div>
<button onClick={handleLogin} disabled={isLoading || !conn} style={{ padding: '10px 15px', marginRight: '10px', borderRadius: '4px', cursor: isLoading || !conn ? 'not-allowed' : 'pointer' }}>
{isLoading ? 'Logging in...' : 'Login'}
<button
onClick={handleLogin}
disabled={isLoading || !conn}
style={{
padding: "10px 15px",
marginRight: "10px",
borderRadius: "4px",
cursor: isLoading || !conn ? "not-allowed" : "pointer",
}}
>
{isLoading ? "Logging in..." : "Login"}
</button>
<button onClick={handleRegister} disabled={isLoading || !conn} style={{ padding: '10px 15px', borderRadius: '4px', cursor: isLoading || !conn ? 'not-allowed' : 'pointer' }}>
{isLoading ? 'Registering...' : 'Register'}
<button
onClick={handleRegister}
disabled={isLoading || !conn}
style={{
padding: "10px 15px",
borderRadius: "4px",
cursor: isLoading || !conn ? "not-allowed" : "pointer",
}}
>
{isLoading ? "Registering..." : "Register"}
</button>
</div>
);
+3 -3
View File
@@ -1,4 +1,4 @@
// src/auth/index.ts
export { OidcProvider, oidcConfig } from './OidcProvider';
export { default as AuthGate } from './AuthGate';
export { default as UsernamePasswordAuth } from './UsernamePasswordAuth';
export { OidcProvider, oidcConfig } from "./OidcProvider";
export { default as AuthGate } from "./AuthGate";
export { default as UsernamePasswordAuth } from "./UsernamePasswordAuth";
+46 -35
View File
@@ -1,15 +1,15 @@
import React, { useState, useEffect } from 'react';
import { useChat, useWebRTC } from './services';
import ServerList from './components/ServerList';
import ChannelList from './components/ChannelList';
import MessageList from './components/MessageList';
import MessageInput from './components/MessageInput';
import MemberList from './components/MemberList';
import ThreadView from './components/ThreadView';
import ServerDiscovery from './components/ServerDiscovery';
import { VideoGrid } from './components/VideoGrid';
import { SettingsPanel } from './components/SettingsPanel';
import { useSpacetimeDB } from 'spacetimedb/react';
import React, { useState, useEffect } from "react";
import { useChat, useWebRTC } from "./services";
import ServerList from "./components/ServerList";
import ChannelList from "./components/ChannelList";
import MessageList from "./components/MessageList";
import MessageInput from "./components/MessageInput";
import MemberList from "./components/MemberList";
import ThreadView from "./components/ThreadView";
import ServerDiscovery from "./components/ServerDiscovery";
import { VideoGrid } from "./components/VideoGrid";
import { SettingsPanel } from "./components/SettingsPanel";
import { useSpacetimeDB } from "spacetimedb/react";
const ChatContainer: React.FC = () => {
const chat = useChat();
@@ -31,7 +31,7 @@ const ChatContainer: React.FC = () => {
isDeafened,
toggleMute,
toggleDeafen,
peerStats
peerStats,
} = useWebRTC(chat.connectedVoiceChannel?.id);
useEffect(() => {
@@ -89,7 +89,7 @@ const ChatContainer: React.FC = () => {
<div className="voice-status-bar">
<div className="voice-info">
<div className="voice-connected-text">
<span style={{ marginRight: '4px' }}>📶</span>
<span style={{ marginRight: "4px" }}>📶</span>
Voice Connected
</div>
<div className="voice-channel-name">
@@ -101,9 +101,9 @@ const ChatContainer: React.FC = () => {
className="icon-btn"
onClick={chat.handleLeaveVoice}
title="Disconnect"
style={{ color: '#f23f43' }}
style={{ color: "#f23f43" }}
>
<span style={{ fontSize: '1.2rem' }}></span>
<span style={{ fontSize: "1.2rem" }}></span>
</button>
</div>
</div>
@@ -113,29 +113,31 @@ const ChatContainer: React.FC = () => {
<div className="user-info-bar">
<div className="user-info-main">
<div className="avatar small">
{chat.currentUser?.name?.[0]?.toUpperCase() || identity?.toHexString().substring(0, 2).toUpperCase()}
{chat.currentUser?.name?.[0]?.toUpperCase() ||
identity?.toHexString().substring(0, 2).toUpperCase()}
</div>
<div className="user-details">
<div className="user-display-name">
{chat.currentUser?.name || identity?.toHexString().substring(0, 8)}
{chat.currentUser?.name ||
identity?.toHexString().substring(0, 8)}
</div>
<div className="user-status">Online</div>
</div>
</div>
<div className="user-actions">
<button
className={`icon-btn ${isMuted ? 'active' : ''}`}
className={`icon-btn ${isMuted ? "active" : ""}`}
onClick={toggleMute}
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? '🎙️❌' : '🎙️'}
{isMuted ? "🎙️❌" : "🎙️"}
</button>
<button
className={`icon-btn ${isDeafened ? 'active' : ''}`}
className={`icon-btn ${isDeafened ? "active" : ""}`}
onClick={toggleDeafen}
title={isDeafened ? "Undeafen" : "Deafen"}
>
{isDeafened ? '🎧❌' : '🎧'}
{isDeafened ? "🎧❌" : "🎧"}
</button>
<button
className="icon-btn"
@@ -148,28 +150,38 @@ const ChatContainer: React.FC = () => {
</div>
</div>
<div className={`main-content ${ (showMemberList || chat.activeThreadId) ? 'has-right-sidebar' : ''}`}>
<div
className={`main-content ${showMemberList || chat.activeThreadId ? "has-right-sidebar" : ""}`}
>
<div className="chat-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
flex: 1,
}}
>
<span className="channel-item-hash">
{chat.isActiveChannelVoice ? '🔊' : '#'}
{chat.isActiveChannelVoice ? "🔊" : "#"}
</span>
{chat.activeChannel?.name || 'Select a channel'}
{chat.activeChannel?.name || "Select a channel"}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{chat.isActiveChannelVoice && chat.connectedVoiceChannel?.id === chat.activeChannel?.id && (
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
{chat.isActiveChannelVoice &&
chat.connectedVoiceChannel?.id === chat.activeChannel?.id && (
<button
className={`screen-share-btn ${isSharingScreen ? 'active' : ''}`}
className={`screen-share-btn ${isSharingScreen ? "active" : ""}`}
onClick={isSharingScreen ? stopScreenShare : startScreenShare}
>
{isSharingScreen ? 'Stop Sharing' : 'Share Screen'}
{isSharingScreen ? "Stop Sharing" : "Share Screen"}
</button>
)}
{!chat.activeThreadId && (
<button
className={`icon-btn ${showMemberList ? 'active' : ''}`}
className={`icon-btn ${showMemberList ? "active" : ""}`}
onClick={() => setShowMemberList(!showMemberList)}
title={showMemberList ? "Hide Member List" : "Show Member List"}
>
@@ -229,7 +241,8 @@ const ChatContainer: React.FC = () => {
voiceStates={chat.voiceStates}
currentVoiceState={chat.currentVoiceState}
connectedVoiceChannel={chat.connectedVoiceChannel}
/> )
/>
)
)}
{chat.showDiscoveryModal && (
@@ -241,9 +254,7 @@ const ChatContainer: React.FC = () => {
/>
)}
{showSettings && (
<SettingsPanel onClose={() => setShowSettings(false)} />
)}
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
</div>
);
};
+217 -82
View File
@@ -33,16 +33,27 @@ interface ChannelListProps {
}
// Helper function (extracted from App.tsx)
const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]) => {
const getUsername = (
userIdentity: Identity | null,
users: readonly Types.User[],
) => {
if (!userIdentity) return "Unknown";
const user = users.find(u => u.identity?.isEqual(userIdentity));
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
const getStatusColor = (status: string | undefined): "green" | "yellow" | "red" => {
if (status === 'connected' || status === 'completed') return 'green';
if (status === 'connecting' || status === 'checking' || status === 'new' || !status) return 'yellow';
return 'red';
const getStatusColor = (
status: string | undefined,
): "green" | "yellow" | "red" => {
if (status === "connected" || status === "completed") return "green";
if (
status === "connecting" ||
status === "checking" ||
status === "new" ||
!status
)
return "yellow";
return "red";
};
const formatBitrate = (bps: number) => {
@@ -51,12 +62,19 @@ const formatBitrate = (bps: number) => {
return `${bps.toFixed(0)} bps`;
};
const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: string, isMe: boolean }> = ({ stats, status, name, isMe }) => {
const ConnectionPopover: React.FC<{
stats?: WebRTCStats;
status: string;
name: string;
isMe: boolean;
}> = ({ stats, status, name, isMe }) => {
return (
<div className="connection-popover">
<div className="popover-header">
<span className="popover-name">{name}</span>
<span className={`popover-status ${getStatusColor(status)}`}>{status}</span>
<span className={`popover-status ${getStatusColor(status)}`}>
{status}
</span>
</div>
{isMe ? (
@@ -65,16 +83,36 @@ const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: s
<div className="popover-stats">
<div className="stats-section">
<div className="section-title">AUDIO</div>
<div className="stat-row"><span>Bitrate</span><span>{formatBitrate(stats.audio.bitrate)}</span></div>
<div className="stat-row"><span>Jitter</span><span>{(stats.audio.jitter * 1000).toFixed(2)} ms</span></div>
<div className="stat-row"><span>Loss</span><span>{stats.audio.packetsLost} pkts</span></div>
<div className="stat-row">
<span>Bitrate</span>
<span>{formatBitrate(stats.audio.bitrate)}</span>
</div>
<div className="stat-row">
<span>Jitter</span>
<span>{(stats.audio.jitter * 1000).toFixed(2)} ms</span>
</div>
<div className="stat-row">
<span>Loss</span>
<span>{stats.audio.packetsLost} pkts</span>
</div>
</div>
{stats.video.bitrate > 0 && (
<div className="stats-section">
<div className="section-title">VIDEO</div>
<div className="stat-row"><span>Bitrate</span><span>{formatBitrate(stats.video.bitrate)}</span></div>
<div className="stat-row"><span>Res</span><span>{stats.video.frameWidth}x{stats.video.frameHeight}</span></div>
<div className="stat-row"><span>FPS</span><span>{stats.video.framesPerSecond.toFixed(0)}</span></div>
<div className="stat-row">
<span>Bitrate</span>
<span>{formatBitrate(stats.video.bitrate)}</span>
</div>
<div className="stat-row">
<span>Res</span>
<span>
{stats.video.frameWidth}x{stats.video.frameHeight}
</span>
</div>
<div className="stat-row">
<span>FPS</span>
<span>{stats.video.framesPerSecond.toFixed(0)}</span>
</div>
</div>
)}
</div>
@@ -86,25 +124,51 @@ const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: s
};
export const ChannelList: React.FC<ChannelListProps> = ({
activeServerId, activeChannelId, setActiveChannelId, setActiveThreadId,
channels, servers, users, identity, voiceStates, currentVoiceState, connectedVoiceChannel, isFullyAuthenticated,
showCreateChannelModal, setShowCreateChannelModal, newChannelName, setNewChannelName, isVoiceChannel, setIsVoiceChannel,
handleCreateChannel, handleJoinVoice, handleLeaveVoice, peerStatuses, watching, peerStats
activeServerId,
activeChannelId,
setActiveChannelId,
setActiveThreadId,
channels,
servers,
users,
identity,
voiceStates,
currentVoiceState,
connectedVoiceChannel,
isFullyAuthenticated,
showCreateChannelModal,
setShowCreateChannelModal,
newChannelName,
setNewChannelName,
isVoiceChannel,
setIsVoiceChannel,
handleCreateChannel,
handleJoinVoice,
handleLeaveVoice,
peerStatuses,
watching,
peerStats,
}) => {
const [hoveredPeer, setHoveredPeer] = React.useState<string | null>(null);
const activeServer = React.useMemo(() =>
servers.find(s => s.id === activeServerId),
[servers, activeServerId]
const activeServer = React.useMemo(
() => servers.find((s) => s.id === activeServerId),
[servers, activeServerId],
);
const textChannels = React.useMemo(() =>
channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Text'),
[channels, activeServerId]
const textChannels = React.useMemo(
() =>
channels.filter(
(c) => c.serverId === activeServerId && c.kind.tag === "Text",
),
[channels, activeServerId],
);
const voiceChannels = React.useMemo(() =>
channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Voice'),
[channels, activeServerId]
const voiceChannels = React.useMemo(
() =>
channels.filter(
(c) => c.serverId === activeServerId && c.kind.tag === "Voice",
),
[channels, activeServerId],
);
if (!activeServer) {
@@ -124,12 +188,20 @@ export const ChannelList: React.FC<ChannelListProps> = ({
<div className="channel-section">
<div className="section-header">
<span>TEXT CHANNELS</span>
<button className="add-btn" onClick={() => { setIsVoiceChannel(false); setShowCreateChannelModal(true); }}>+</button>
<button
className="add-btn"
onClick={() => {
setIsVoiceChannel(false);
setShowCreateChannelModal(true);
}}
>
+
</button>
</div>
{textChannels.map(channel => (
{textChannels.map((channel) => (
<div
key={channel.id.toString()}
className={`channel-item ${activeChannelId === channel.id ? 'active' : ''}`}
className={`channel-item ${activeChannelId === channel.id ? "active" : ""}`}
onClick={() => {
setActiveChannelId(channel.id);
setActiveThreadId(null);
@@ -144,94 +216,146 @@ export const ChannelList: React.FC<ChannelListProps> = ({
<div className="channel-section">
<div className="section-header">
<span>VOICE CHANNELS</span>
<button className="add-btn" onClick={() => { setIsVoiceChannel(true); setShowCreateChannelModal(true); }}>+</button>
<button
className="add-btn"
onClick={() => {
setIsVoiceChannel(true);
setShowCreateChannelModal(true);
}}
>
+
</button>
</div>
{voiceChannels.map(channel => (
{voiceChannels.map((channel) => (
<div key={channel.id.toString()}>
<div
className={`channel-item ${activeChannelId === channel.id ? 'active' : ''}`}
className={`channel-item ${activeChannelId === channel.id ? "active" : ""}`}
onClick={() => {
handleJoinVoice(channel.id);
setActiveChannelId(channel.id);
setActiveThreadId(null);
}}
style={{cursor: isFullyAuthenticated ? 'pointer' : 'not-allowed'}}
style={{
cursor: isFullyAuthenticated ? "pointer" : "not-allowed",
}}
>
<span className="channel-item-hash">🔊</span>
{channel.name}
</div>
{/* Voice Channel Members */}
<div style={{paddingLeft: '16px', display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px'}}>
{voiceStates.filter(vs => vs.channelId === channel.id).map(vs => {
<div
style={{
paddingLeft: "16px",
display: "flex",
flexDirection: "column",
gap: "4px",
marginBottom: "8px",
}}
>
{voiceStates
.filter((vs) => vs.channelId === channel.id)
.map((vs) => {
const peerIdHex = vs.identity.toHexString();
const isMe = identity?.isEqual(vs.identity);
const status = peerStatuses.get(peerIdHex);
const user = users.find(u => u.identity?.isEqual(vs.identity));
const user = users.find((u) =>
u.identity?.isEqual(vs.identity),
);
const isTalking = user?.talking || false;
const isSharing = vs.isSharingScreen;
const amIWatching = watching.some(w => w.watcher.isEqual(identity!) && w.watchee.isEqual(vs.identity));
const amIWatching = watching.some(
(w) =>
w.watcher.isEqual(identity!) &&
w.watchee.isEqual(vs.identity),
);
const voiceStatusColor = isMe ? 'green' : getStatusColor(status);
const videoStatusColor = isMe ? (isSharing ? 'green' : undefined) : (isSharing ? getStatusColor(status) : undefined);
const voiceStatusColor = isMe
? "green"
: getStatusColor(status);
const videoStatusColor = isMe
? isSharing
? "green"
: undefined
: isSharing
? getStatusColor(status)
: undefined;
// Consolidate into one dot: priority Red > Yellow > Green
let finalStatusColor: "green" | "yellow" | "red" = voiceStatusColor;
if (videoStatusColor === 'red') finalStatusColor = 'red';
else if (videoStatusColor === 'yellow' && finalStatusColor === 'green') finalStatusColor = 'yellow';
let finalStatusColor: "green" | "yellow" | "red" =
voiceStatusColor;
if (videoStatusColor === "red") finalStatusColor = "red";
else if (
videoStatusColor === "yellow" &&
finalStatusColor === "green"
)
finalStatusColor = "yellow";
return (
<div
key={peerIdHex}
className="voice-member-item"
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '0.85rem',
color: 'var(--text-muted)',
height: '24px',
position: 'relative',
padding: '2px 4px',
borderRadius: '4px'
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "0.85rem",
color: "var(--text-muted)",
height: "24px",
position: "relative",
padding: "2px 4px",
borderRadius: "4px",
}}
>
<div
className="avatar"
style={{
width: '18px',
height: '18px',
fontSize: '0.5rem',
backgroundColor: 'var(--brand)',
border: isTalking ? '2px solid #23a559' : '2px solid transparent',
boxShadow: isTalking ? '0 0 4px #23a559' : 'none',
transition: 'all 0.1s ease-in-out',
flexShrink: 0
width: "18px",
height: "18px",
fontSize: "0.5rem",
backgroundColor: "var(--brand)",
border: isTalking
? "2px solid #23a559"
: "2px solid transparent",
boxShadow: isTalking ? "0 0 4px #23a559" : "none",
transition: "all 0.1s ease-in-out",
flexShrink: 0,
}}
>
{getUsername(vs.identity, users).substring(0, 2).toUpperCase()}
{getUsername(vs.identity, users)
.substring(0, 2)
.toUpperCase()}
</div>
<span className="member-name" style={{ color: isTalking ? 'white' : 'inherit' }}>{getUsername(vs.identity, users)}</span>
<span
className="member-name"
style={{ color: isTalking ? "white" : "inherit" }}
>
{getUsername(vs.identity, users)}
</span>
{isSharing && (
<span style={{
backgroundColor: '#f23f43',
color: 'white',
fontSize: '0.6rem',
padding: '1px 4px',
borderRadius: '3px',
fontWeight: 'bold',
marginLeft: '4px',
flexShrink: 0
}}>LIVE</span>
<span
style={{
backgroundColor: "#f23f43",
color: "white",
fontSize: "0.6rem",
padding: "1px 4px",
borderRadius: "3px",
fontWeight: "bold",
marginLeft: "4px",
flexShrink: 0,
}}
>
LIVE
</span>
)}
<div
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
height: '100%',
padding: '0 4px',
cursor: 'help'
marginLeft: "auto",
display: "flex",
alignItems: "center",
height: "100%",
padding: "0 4px",
cursor: "help",
}}
onMouseEnter={() => setHoveredPeer(peerIdHex)}
onMouseLeave={() => setHoveredPeer(null)}
@@ -242,7 +366,7 @@ export const ChannelList: React.FC<ChannelListProps> = ({
{hoveredPeer === peerIdHex && (
<ConnectionPopover
stats={peerStats.get(peerIdHex)}
status={isMe ? 'connected' : (status || 'connecting')}
status={isMe ? "connected" : status || "connecting"}
name={getUsername(vs.identity, users)}
isMe={isMe}
/>
@@ -258,7 +382,7 @@ export const ChannelList: React.FC<ChannelListProps> = ({
{showCreateChannelModal && (
<div className="modal-overlay">
<form className="modal-content" onSubmit={handleCreateChannel}>
<h2>Create {isVoiceChannel ? 'Voice' : 'Text'} Channel</h2>
<h2>Create {isVoiceChannel ? "Voice" : "Text"} Channel</h2>
<input
autoFocus
placeholder="channel-name"
@@ -266,8 +390,19 @@ export const ChannelList: React.FC<ChannelListProps> = ({
onChange={(e) => setNewChannelName(e.target.value)}
/>
<div className="modal-actions">
<button type="button" onClick={() => setShowCreateChannelModal(false)}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!isFullyAuthenticated}>Create</button>
<button
type="button"
onClick={() => setShowCreateChannelModal(false)}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={!isFullyAuthenticated}
>
Create
</button>
</div>
</form>
</div>
+91 -39
View File
@@ -1,13 +1,16 @@
// src/chat/components/MemberList.tsx
import React, { useMemo } from 'react';
import { Identity } from 'spacetimedb';
import type * as Types from '../../module_bindings/types';
import { tables } from '../../module_bindings';
import React, { useMemo } from "react";
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
import { tables } from "../../module_bindings";
// Helper function (extracted from App.tsx)
const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]) => {
const getUsername = (
userIdentity: Identity | null,
users: readonly Types.User[],
) => {
if (!userIdentity) return "Unknown";
const user = users.find(u => u.identity?.isEqual(userIdentity));
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
@@ -21,54 +24,89 @@ interface MemberListProps {
connectedVoiceChannel: Types.Channel | undefined;
}
function MemberList({ activeServerMembers, users, identity, activeServer, voiceStates, currentVoiceState, connectedVoiceChannel }: MemberListProps) {
function MemberList({
activeServerMembers,
users,
identity,
activeServer,
voiceStates,
currentVoiceState,
connectedVoiceChannel,
}: MemberListProps) {
// Categorize members into Online and Offline
const onlineMembers = useMemo(() =>
activeServerMembers.filter(m => m.online),
[activeServerMembers]
const onlineMembers = useMemo(
() => activeServerMembers.filter((m) => m.online),
[activeServerMembers],
);
const offlineMembers = useMemo(() =>
activeServerMembers.filter(m => !m.online),
[activeServerMembers]
const offlineMembers = useMemo(
() => activeServerMembers.filter((m) => !m.online),
[activeServerMembers],
);
const renderMember = (user: Types.User, isOffline: boolean = false) => {
const userVoiceState = voiceStates.find(vs => vs.identity.isEqual(user.identity));
const userVoiceState = voiceStates.find((vs) =>
vs.identity.isEqual(user.identity),
);
const isTalking = user.talking || false;
const isSharing = userVoiceState?.isSharingScreen || false;
const isMe = identity?.isEqual(user.identity);
return (
<div key={user.identity.toHexString()} className="member-item" style={{ opacity: isOffline ? 0.5 : 1 }}>
<div
key={user.identity.toHexString()}
className="member-item"
style={{ opacity: isOffline ? 0.5 : 1 }}
>
<div
className="avatar small"
style={{
width: '24px',
height: '24px',
fontSize: '0.7rem',
backgroundColor: 'var(--background-tertiary)',
border: isTalking && !isOffline ? '2px solid #23a559' : '2px solid transparent',
boxShadow: isTalking && !isOffline ? '0 0 4px #23a559' : 'none',
transition: 'all 0.1s ease-in-out'
width: "24px",
height: "24px",
fontSize: "0.7rem",
backgroundColor: "var(--background-tertiary)",
border:
isTalking && !isOffline
? "2px solid #23a559"
: "2px solid transparent",
boxShadow: isTalking && !isOffline ? "0 0 4px #23a559" : "none",
transition: "all 0.1s ease-in-out",
}}
>
{(user.name || user.identity.toHexString()).substring(0, 2).toUpperCase()}
{(user.name || user.identity.toHexString())
.substring(0, 2)
.toUpperCase()}
</div>
<span className="member-name" style={{ color: isTalking && !isOffline ? 'white' : 'inherit' }}>
<span
className="member-name"
style={{ color: isTalking && !isOffline ? "white" : "inherit" }}
>
{user.name || user.identity.toHexString().substring(0, 8)}
{isMe && <span style={{ color: 'var(--text-muted)', fontSize: '0.7rem', marginLeft: '4px' }}>(You)</span>}
{isMe && (
<span
style={{
color: "var(--text-muted)",
fontSize: "0.7rem",
marginLeft: "4px",
}}
>
(You)
</span>
)}
</span>
{isSharing && !isOffline && (
<span style={{
backgroundColor: '#f23f43',
color: 'white',
fontSize: '0.6rem',
padding: '1px 4px',
borderRadius: '3px',
fontWeight: 'bold',
marginLeft: 'auto'
}}>LIVE</span>
<span
style={{
backgroundColor: "#f23f43",
color: "white",
fontSize: "0.6rem",
padding: "1px 4px",
borderRadius: "3px",
fontWeight: "bold",
marginLeft: "auto",
}}
>
LIVE
</span>
)}
</div>
);
@@ -79,19 +117,33 @@ function MemberList({ activeServerMembers, users, identity, activeServer, voiceS
<div className="member-list">
{onlineMembers.length > 0 && (
<>
<div style={{padding: '0 8px 8px 8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>
<div
style={{
padding: "0 8px 8px 8px",
fontSize: "0.75rem",
fontWeight: "bold",
color: "var(--text-muted)",
}}
>
ONLINE {onlineMembers.length}
</div>
{onlineMembers.map(user => renderMember(user))}
{onlineMembers.map((user) => renderMember(user))}
</>
)}
{offlineMembers.length > 0 && (
<>
<div style={{padding: '16px 8px 8px 8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>
<div
style={{
padding: "16px 8px 8px 8px",
fontSize: "0.75rem",
fontWeight: "bold",
color: "var(--text-muted)",
}}
>
OFFLINE {offlineMembers.length}
</div>
{offlineMembers.map(user => renderMember(user, true))}
{offlineMembers.map((user) => renderMember(user, true))}
</>
)}
</div>
+19 -6
View File
@@ -1,5 +1,5 @@
// src/chat/components/MessageInput.tsx
import React, { useState } from 'react';
import React, { useState } from "react";
interface MessageInputProps {
activeChannelId: bigint | null;
@@ -8,23 +8,36 @@ interface MessageInputProps {
sendMessageReducer: (args: any) => void;
}
function MessageInput({ activeChannelId, activeThreadId, isFullyAuthenticated, sendMessageReducer }: MessageInputProps) {
const [messageText, setMessageText] = useState('');
function MessageInput({
activeChannelId,
activeThreadId,
isFullyAuthenticated,
sendMessageReducer,
}: MessageInputProps) {
const [messageText, setMessageText] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!messageText.trim() || !activeChannelId) return;
// Call the sendMessage reducer
sendMessageReducer({ text: messageText, channelId: activeChannelId, threadId: activeThreadId });
setMessageText('');
sendMessageReducer({
text: messageText,
channelId: activeChannelId,
threadId: activeThreadId,
});
setMessageText("");
};
return (
<div className="chat-input-container">
<form className="chat-input" onSubmit={handleSubmit}>
<input
placeholder={isFullyAuthenticated ? `Message ${activeThreadId ? 'in thread...' : '#channel'}` : "Log in to chat"}
placeholder={
isFullyAuthenticated
? `Message ${activeThreadId ? "in thread..." : "#channel"}`
: "Log in to chat"
}
disabled={!isFullyAuthenticated || !activeChannelId}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
+52 -29
View File
@@ -1,22 +1,25 @@
// src/chat/components/MessageList.tsx
import React, { useRef, useEffect, useMemo } from 'react';
import { Identity } from 'spacetimedb';
import type * as Types from '../../module_bindings/types';
import { useTable } from 'spacetimedb/react';
import { tables } from '../../module_bindings';
import React, { useRef, useEffect, useMemo } from "react";
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
import { useTable } from "spacetimedb/react";
import { tables } from "../../module_bindings";
import RichText from './RichText';
import RichText from "./RichText";
// Helper function (extracted from App.tsx)
const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]) => {
const getUsername = (
userIdentity: Identity | null,
users: readonly Types.User[],
) => {
if (!userIdentity) return "Unknown";
const user = users.find(u => u.identity?.isEqual(userIdentity));
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
const formatTime = (ts: any) => {
const date = new Date(Number(ts.microsSinceUnixEpoch / 1000n));
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
interface MessageListProps {
@@ -29,12 +32,20 @@ interface MessageListProps {
isFullyAuthenticated: boolean;
}
function MessageList({ messages, activeThreadId, setActiveThreadId, users, identity, handleStartThread, isFullyAuthenticated }: MessageListProps) {
function MessageList({
messages,
activeThreadId,
setActiveThreadId,
users,
identity,
handleStartThread,
isFullyAuthenticated,
}: MessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Fetch threads to display thread links
@@ -42,13 +53,25 @@ function MessageList({ messages, activeThreadId, setActiveThreadId, users, ident
return (
<div className="message-list">
{messages.map(msg => {
{messages.map((msg) => {
const msgUsername = getUsername(msg.sender, users);
const existingThread = allThreads.find(t => t.parentMessageId === msg.id);
const existingThread = allThreads.find(
(t) => t.parentMessageId === msg.id,
);
return (
<div key={msg.id.toString()} className="message-item">
<div className="message-avatar" style={{fontSize: '0.9rem', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 'bold'}}>
<div
className="message-avatar"
style={{
fontSize: "0.9rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
}}
>
{msgUsername.substring(0, 2).toUpperCase()}
</div>
<div className="message-content">
@@ -61,14 +84,14 @@ function MessageList({ messages, activeThreadId, setActiveThreadId, users, ident
onClick={() => handleStartThread(msg)}
title="Start Thread"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '0.9rem',
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.9rem",
opacity: 0.6,
marginLeft: '8px',
padding: '2px 4px',
borderRadius: '4px'
marginLeft: "8px",
padding: "2px 4px",
borderRadius: "4px",
}}
>
💬
@@ -84,16 +107,16 @@ function MessageList({ messages, activeThreadId, setActiveThreadId, users, ident
className="thread-link"
onClick={() => setActiveThreadId(existingThread.id)}
style={{
marginTop: '4px',
paddingLeft: '12px',
borderLeft: '2px solid var(--background-accent)',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '0.85rem'
marginTop: "4px",
paddingLeft: "12px",
borderLeft: "2px solid var(--background-accent)",
display: "flex",
alignItems: "center",
gap: "4px",
fontSize: "0.85rem",
}}
>
<span style={{ fontSize: '0.8rem' }}></span>
<span style={{ fontSize: "0.8rem" }}></span>
View Thread ({existingThread.name})
</div>
)}
+15 -6
View File
@@ -1,11 +1,11 @@
import React from 'react';
import React from "react";
interface RichTextProps {
text: string;
}
const isImageUrl = (url: string) => {
return (url.match(/\.(jpeg|jpg|gif|png|webp|svg)$/i) != null);
return url.match(/\.(jpeg|jpg|gif|png|webp|svg)$/i) != null;
};
const RichText: React.FC<RichTextProps> = ({ text }) => {
@@ -18,7 +18,12 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
if (part.match(urlRegex)) {
return (
<React.Fragment key={i}>
<a href={part} target="_blank" rel="noopener noreferrer" className="url-link">
<a
href={part}
target="_blank"
rel="noopener noreferrer"
className="url-link"
>
{part}
</a>
{isImageUrl(part) && (
@@ -29,19 +34,23 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
className="message-image"
onLoad={(e) => {
// Trigger scroll to bottom when image loads by finding closest scrollable list
const list = e.currentTarget.closest('.message-list');
const list = e.currentTarget.closest(".message-list");
if (list) {
list.scrollTop = list.scrollHeight;
}
}}
onClick={() => window.open(part, '_blank')}
onClick={() => window.open(part, "_blank")}
/>
</div>
)}
</React.Fragment>
);
}
return <span key={i} style={{ whiteSpace: 'pre-wrap' }}>{part}</span>;
return (
<span key={i} style={{ whiteSpace: "pre-wrap" }}>
{part}
</span>
);
})}
</div>
);
+63 -22
View File
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import type * as Types from '../../module_bindings/types';
import React, { useState, useMemo } from "react";
import type * as Types from "../../module_bindings/types";
interface ServerDiscoveryProps {
availableServers: readonly Types.Server[];
@@ -8,21 +8,43 @@ interface ServerDiscoveryProps {
isFullyAuthenticated: boolean;
}
function ServerDiscovery({ availableServers, handleJoinServer, onClose, isFullyAuthenticated }: ServerDiscoveryProps) {
const [searchTerm, setSearchTerm] = useState('');
function ServerDiscovery({
availableServers,
handleJoinServer,
onClose,
isFullyAuthenticated,
}: ServerDiscoveryProps) {
const [searchTerm, setSearchTerm] = useState("");
const filteredServers = useMemo(() => {
return availableServers.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase())
return availableServers.filter((s) =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [availableServers, searchTerm]);
return (
<div className="modal-overlay">
<div className="modal-content" style={{ width: '500px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div
className="modal-content"
style={{
width: "500px",
maxHeight: "80vh",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "16px",
}}
>
<h2 style={{ margin: 0 }}>Discover Servers</h2>
<button type="button" className="close-btn" onClick={onClose}>×</button>
<button type="button" className="close-btn" onClick={onClose}>
×
</button>
</div>
<input
@@ -30,30 +52,49 @@ function ServerDiscovery({ availableServers, handleJoinServer, onClose, isFullyA
placeholder="Search for servers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ marginBottom: '16px' }}
style={{ marginBottom: "16px" }}
/>
<div style={{ overflowY: 'auto', flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div
style={{
overflowY: "auto",
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{filteredServers.length === 0 ? (
<p style={{ textAlign: 'center', color: 'var(--text-muted)' }}>No servers found.</p>
<p style={{ textAlign: "center", color: "var(--text-muted)" }}>
No servers found.
</p>
) : (
filteredServers.map(server => (
filteredServers.map((server) => (
<div
key={server.id.toString()}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px',
backgroundColor: 'var(--background-secondary)',
borderRadius: '8px'
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px",
backgroundColor: "var(--background-secondary)",
borderRadius: "8px",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: "12px" }}
>
<div
className="server-icon"
style={{
width: "40px",
height: "40px",
fontSize: "0.9rem",
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div className="server-icon" style={{ width: '40px', height: '40px', fontSize: '0.9rem' }}>
{server.name.substring(0, 2).toUpperCase()}
</div>
<span style={{ fontWeight: 'bold' }}>{server.name}</span>
<span style={{ fontWeight: "bold" }}>{server.name}</span>
</div>
<button
className="btn-primary"
+71 -19
View File
@@ -1,14 +1,14 @@
// src/chat/components/ServerList.tsx
import React, { useState, useMemo } from 'react';
import { Identity } from 'spacetimedb';
import type * as Types from '../../module_bindings/types';
import { useTable, useReducer } from 'spacetimedb/react'; // Assuming useTable and useReducer are available
import { tables, reducers } from '../../module_bindings';
import React, { useState, useMemo } from "react";
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
import { useTable, useReducer } from "spacetimedb/react"; // Assuming useTable and useReducer are available
import { tables, reducers } from "../../module_bindings";
// Helper function (extracted from App.tsx)
const getUsername = (userIdentity: Identity | null, users: Types.User[]) => {
if (!userIdentity) return "Unknown";
const user = users.find(u => u.identity?.isEqual(userIdentity));
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
@@ -30,17 +30,26 @@ interface ServerListProps {
}
function ServerList({
joinedServers, activeServerId, setActiveServerId, isFullyAuthenticated, identity, users,
showCreateServerModal, setShowCreateServerModal, newServerName, setNewServerName, handleCreateServer,
setShowDiscoveryModal, handleLeaveServer
joinedServers,
activeServerId,
setActiveServerId,
isFullyAuthenticated,
identity,
users,
showCreateServerModal,
setShowCreateServerModal,
newServerName,
setNewServerName,
handleCreateServer,
setShowDiscoveryModal,
handleLeaveServer,
}: ServerListProps) {
return (
<div className="server-list">
{joinedServers.map(server => (
<div key={server.id.toString()} style={{ position: 'relative' }}>
{joinedServers.map((server) => (
<div key={server.id.toString()} style={{ position: "relative" }}>
<div
className={`server-icon ${activeServerId === server.id ? 'active' : ''}`}
className={`server-icon ${activeServerId === server.id ? "active" : ""}`}
onClick={() => setActiveServerId(server.id)}
title={server.name}
>
@@ -48,8 +57,25 @@ function ServerList({
</div>
{activeServerId === server.id && (
<div
onClick={(e) => { e.stopPropagation(); handleLeaveServer(server.id); }}
style={{ position: 'absolute', top: -5, right: -5, backgroundColor: '#da373c', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, cursor: 'pointer', border: '2px solid var(--background-tertiary)' }}
onClick={(e) => {
e.stopPropagation();
handleLeaveServer(server.id);
}}
style={{
position: "absolute",
top: -5,
right: -5,
backgroundColor: "#da373c",
borderRadius: "50%",
width: 16,
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
cursor: "pointer",
border: "2px solid var(--background-tertiary)",
}}
title="Leave Server"
>
×
@@ -57,8 +83,22 @@ function ServerList({
)}
</div>
))}
<div className="server-icon" onClick={() => setShowCreateServerModal(true)} style={{ cursor: isFullyAuthenticated ? 'pointer' : 'not-allowed' }} title="Create Server">+</div>
<div className="server-icon" onClick={() => setShowDiscoveryModal(true)} style={{ color: '#23a559' }} title="Discover Servers">🔍</div>
<div
className="server-icon"
onClick={() => setShowCreateServerModal(true)}
style={{ cursor: isFullyAuthenticated ? "pointer" : "not-allowed" }}
title="Create Server"
>
+
</div>
<div
className="server-icon"
onClick={() => setShowDiscoveryModal(true)}
style={{ color: "#23a559" }}
title="Discover Servers"
>
🔍
</div>
{/* Create Server Modal */}
{showCreateServerModal && (
@@ -72,8 +112,20 @@ function ServerList({
onChange={(e) => setNewServerName(e.target.value)}
/>
<div className="modal-buttons">
<button type="button" className="btn-secondary" onClick={() => setShowCreateServerModal(false)}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!isFullyAuthenticated}>Create</button>
<button
type="button"
className="btn-secondary"
onClick={() => setShowCreateServerModal(false)}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={!isFullyAuthenticated}
>
Create
</button>
</div>
</form>
</div>
+28 -11
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useReducer, useSpacetimeDB, useTable } from 'spacetimedb/react';
import { reducers, tables } from '../../module_bindings';
import React, { useState } from "react";
import { useReducer, useSpacetimeDB, useTable } from "spacetimedb/react";
import { reducers, tables } from "../../module_bindings";
interface SettingsPanelProps {
onClose: () => void;
@@ -9,9 +9,9 @@ interface SettingsPanelProps {
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
const { identity } = useSpacetimeDB();
const [users] = useTable(tables.user);
const currentUser = users.find(u => u.identity.isEqual(identity!));
const currentUser = users.find((u) => u.identity.isEqual(identity!));
const [name, setNameInput] = useState(currentUser?.name || '');
const [name, setNameInput] = useState(currentUser?.name || "");
const setNameReducer = useReducer(reducers.setName);
const handleSave = () => {
@@ -23,24 +23,41 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>User Settings</h2>
<div className="settings-section">
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)', textTransform: 'uppercase' }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontSize: "0.75rem",
fontWeight: "bold",
color: "var(--text-muted)",
textTransform: "uppercase",
}}
>
Display Name
</label>
<input
type="text"
value={name}
onChange={e => setNameInput(e.target.value)}
onChange={(e) => setNameInput(e.target.value)}
placeholder="Enter your display name"
autoFocus
style={{ width: '100%', boxSizing: 'border-box' }}
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
<div className="modal-actions">
<button className="icon-btn" style={{ padding: '8px 16px', fontSize: '0.9rem' }} onClick={onClose}>Cancel</button>
<button className="btn-primary" onClick={handleSave}>Save Changes</button>
<button
className="icon-btn"
style={{ padding: "8px 16px", fontSize: "0.9rem" }}
onClick={onClose}
>
Cancel
</button>
<button className="btn-primary" onClick={handleSave}>
Save Changes
</button>
</div>
</div>
</div>
+25 -11
View File
@@ -1,6 +1,6 @@
// src/chat/components/ThreadMessageInput.tsx
import React, { useState } from 'react';
import { tables, reducers } from '../../module_bindings';
import React, { useState } from "react";
import { tables, reducers } from "../../module_bindings";
interface ThreadMessageInputProps {
activeChannelId: bigint | null; // Still needed by sendMessage reducer
@@ -9,25 +9,39 @@ interface ThreadMessageInputProps {
sendMessageReducer: (args: any) => void;
}
function ThreadMessageInput({ activeChannelId, activeThreadId, isFullyAuthenticated, sendMessageReducer }: ThreadMessageInputProps) {
const [threadMessageText, setThreadMessageText] = useState('');
function ThreadMessageInput({
activeChannelId,
activeThreadId,
isFullyAuthenticated,
sendMessageReducer,
}: ThreadMessageInputProps) {
const [threadMessageText, setThreadMessageText] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!threadMessageText.trim() || !activeChannelId || !activeThreadId) return;
if (!threadMessageText.trim() || !activeChannelId || !activeThreadId)
return;
sendMessageReducer({ text: threadMessageText, channelId: activeChannelId, threadId: activeThreadId });
setThreadMessageText('');
sendMessageReducer({
text: threadMessageText,
channelId: activeChannelId,
threadId: activeThreadId,
});
setThreadMessageText("");
};
return (
<div className="chat-input-container" style={{padding: '8px'}}>
<div className="chat-input-container" style={{ padding: "8px" }}>
<form className="chat-input-wrapper" onSubmit={handleSubmit}>
<input
className="chat-input"
style={{fontSize: '0.85rem'}}
placeholder={isFullyAuthenticated ? "Reply in thread..." : "Log in to chat"}
disabled={!isFullyAuthenticated || !activeChannelId || !activeThreadId}
style={{ fontSize: "0.85rem" }}
placeholder={
isFullyAuthenticated ? "Reply in thread..." : "Log in to chat"
}
disabled={
!isFullyAuthenticated || !activeChannelId || !activeThreadId
}
value={threadMessageText}
onChange={(e) => setThreadMessageText(e.target.value)}
/>
+42 -14
View File
@@ -1,13 +1,16 @@
// src/chat/components/ThreadMessageList.tsx
import React, { useRef, useEffect } from 'react';
import { Identity } from 'spacetimedb';
import type * as Types from '../../module_bindings/types';
import RichText from './RichText';
import React, { useRef, useEffect } from "react";
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
import RichText from "./RichText";
// Helper function (extracted from App.tsx)
const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]) => {
const getUsername = (
userIdentity: Identity | null,
users: readonly Types.User[],
) => {
if (!userIdentity) return "Unknown";
const user = users.find(u => u.identity?.isEqual(userIdentity));
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
@@ -17,29 +20,54 @@ interface ThreadMessageListProps {
identity: Identity | null;
}
function ThreadMessageList({ threadMessages, users, identity }: ThreadMessageListProps) {
function ThreadMessageList({
threadMessages,
users,
identity,
}: ThreadMessageListProps) {
const threadMessagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
threadMessagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
threadMessagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [threadMessages]);
return (
<div className="message-list thread-messages-list" style={{ padding: '8px' }}>
{threadMessages.map(msg => {
<div
className="message-list thread-messages-list"
style={{ padding: "8px" }}
>
{threadMessages.map((msg) => {
const msgUsername = getUsername(msg.sender, users);
return (
<div key={msg.id.toString()} className="message-item" style={{ padding: '4px 8px', gap: '12px' }}>
<div className="message-avatar" style={{ width: '32px', height: '32px', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 'bold' }}>
<div
key={msg.id.toString()}
className="message-item"
style={{ padding: "4px 8px", gap: "12px" }}
>
<div
className="message-avatar"
style={{
width: "32px",
height: "32px",
fontSize: "0.8rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
}}
>
{msgUsername.substring(0, 2).toUpperCase()}
</div>
<div className="message-content">
<div className="message-header">
<span className="user-name" style={{ fontSize: '0.85rem' }}>{msgUsername}</span>
<span className="user-name" style={{ fontSize: "0.85rem" }}>
{msgUsername}
</span>
</div>
<div className="message-text" style={{ fontSize: '0.85rem' }}>
<div className="message-text" style={{ fontSize: "0.85rem" }}>
<RichText text={msg.text} />
</div>
</div>
+58 -20
View File
@@ -1,11 +1,11 @@
// src/chat/components/ThreadView.tsx
import React, { useState, useMemo } from 'react';
import { Identity } from 'spacetimedb';
import type * as Types from '../../module_bindings/types';
import { useTable, useReducer } from 'spacetimedb/react'; // Assuming useTable and useReducer are available
import { tables, reducers } from '../../module_bindings';
import ThreadMessageList from './ThreadMessageList';
import ThreadMessageInput from './ThreadMessageInput';
import React, { useState, useMemo } from "react";
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
import { useTable, useReducer } from "spacetimedb/react"; // Assuming useTable and useReducer are available
import { tables, reducers } from "../../module_bindings";
import ThreadMessageList from "./ThreadMessageList";
import ThreadMessageInput from "./ThreadMessageInput";
interface ThreadViewProps {
activeThreadId: bigint;
@@ -17,23 +17,35 @@ interface ThreadViewProps {
identity: Identity | null;
}
function ThreadView({ activeThreadId, setActiveThreadId, activeChannelId, activeServer, isFullyAuthenticated, users, identity }: ThreadViewProps) {
const sendMessageReducer = useReducer(useMemo(() => reducers.sendMessage, [])); // Assuming reducers are accessible
function ThreadView({
activeThreadId,
setActiveThreadId,
activeChannelId,
activeServer,
isFullyAuthenticated,
users,
identity,
}: ThreadViewProps) {
const sendMessageReducer = useReducer(
useMemo(() => reducers.sendMessage, []),
); // Assuming reducers are accessible
// Fetch all threads and messages
const [allThreads] = useTable(useMemo(() => tables.thread, []));
const [allMessages] = useTable(useMemo(() => tables.message, []));
const activeThread = useMemo(() =>
allThreads.find(t => t.id === activeThreadId),
[allThreads, activeThreadId]
const activeThread = useMemo(
() => allThreads.find((t) => t.id === activeThreadId),
[allThreads, activeThreadId],
);
const threadMessages = useMemo(() => {
if (!activeThreadId) return [];
return allMessages
.filter(m => m.threadId === activeThreadId)
.sort((a, b) => a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1);
.filter((m) => m.threadId === activeThreadId)
.sort((a, b) =>
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1,
);
}, [allMessages, activeThreadId]);
if (!activeThreadId || !activeThread) {
@@ -42,15 +54,41 @@ function ThreadView({ activeThreadId, setActiveThreadId, activeChannelId, active
return (
<div className="thread-view">
<div className="thread-header" style={{borderBottom: '1px solid var(--background-accent)', padding: '8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<div style={{display: 'flex', alignItems: 'center', gap: '8px'}}>
<span style={{color: 'var(--brand)', cursor: 'pointer', fontSize: '1.2rem'}} onClick={() => setActiveThreadId(null)}></span>
<span style={{fontWeight: 'bold', fontSize: '0.9rem'}}>{activeThread.name}</span>
<div
className="thread-header"
style={{
borderBottom: "1px solid var(--background-accent)",
padding: "8px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span
style={{
color: "var(--brand)",
cursor: "pointer",
fontSize: "1.2rem",
}}
onClick={() => setActiveThreadId(null)}
>
</span>
<span style={{ fontWeight: "bold", fontSize: "0.9rem" }}>
{activeThread.name}
</span>
</div>
<button className="close-btn" onClick={() => setActiveThreadId(null)}>×</button>
<button className="close-btn" onClick={() => setActiveThreadId(null)}>
×
</button>
</div>
<ThreadMessageList threadMessages={threadMessages} users={users} identity={identity} />
<ThreadMessageList
threadMessages={threadMessages}
users={users}
identity={identity}
/>
<ThreadMessageInput
activeChannelId={activeChannelId}
+83 -50
View File
@@ -1,8 +1,8 @@
import React, { useEffect, useRef } from 'react';
import { Identity } from 'spacetimedb';
import { useTable, useSpacetimeDB } from 'spacetimedb/react';
import { tables } from '../../module_bindings';
import * as Types from '../../module_bindings/types';
import React, { useEffect, useRef } from "react";
import { Identity } from "spacetimedb";
import { useTable, useSpacetimeDB } from "spacetimedb/react";
import { tables } from "../../module_bindings";
import * as Types from "../../module_bindings/types";
interface VideoGridProps {
peers: Map<string, { audio?: HTMLAudioElement; videoStream?: MediaStream }>;
@@ -21,7 +21,7 @@ const VideoTile = ({
onToggleWatch,
isWatching,
isSharing,
isHero
isHero,
}: {
identity: Identity;
stream?: MediaStream;
@@ -36,26 +36,31 @@ const VideoTile = ({
const containerRef = useRef<HTMLDivElement>(null);
const [users] = useTable(tables.user);
const [isMuted, setIsMuted] = React.useState(true);
const user = users.find(u => u.identity.isEqual(identity));
const name = user?.name || user?.username || identity.toHexString().substring(0, 8);
const user = users.find((u) => u.identity.isEqual(identity));
const name =
user?.name || user?.username || identity.toHexString().substring(0, 8);
useEffect(() => {
const video = videoRef.current;
const shouldShow = isLocal || isWatching;
console.log(`[VideoTile] ${name}: isLocal=${isLocal}, isWatching=${isWatching}, isSharing=${isSharing}, hasStream=${!!stream}, isMuted=${isMuted}`);
console.log(
`[VideoTile] ${name}: isLocal=${isLocal}, isWatching=${isWatching}, isSharing=${isSharing}, hasStream=${!!stream}, isMuted=${isMuted}`,
);
if (video && stream && shouldShow) {
if (video.srcObject !== stream) {
console.log(`[VideoTile] Linking stream to ${name} (${isLocal ? 'local' : 'remote'})`);
console.log(
`[VideoTile] Linking stream to ${name} (${isLocal ? "local" : "remote"})`,
);
video.srcObject = stream;
}
// Muted is usually required for autoplay, but if the user has interacted
// (e.g. clicked unmute), we can change it.
video.muted = isMuted;
video.play().catch(err => {
if (err.name !== 'AbortError') {
video.play().catch((err) => {
if (err.name !== "AbortError") {
console.warn(`[VideoTile] Play failed for ${name}:`, err);
}
});
@@ -76,7 +81,7 @@ const VideoTile = ({
const toggleMute = (e: React.MouseEvent) => {
e.stopPropagation();
setIsMuted(prev => !prev);
setIsMuted((prev) => !prev);
};
const showStream = (isLocal || isWatching) && stream;
@@ -84,80 +89,104 @@ const VideoTile = ({
return (
<div
ref={containerRef}
className={`video-tile ${isTalking ? 'talking' : ''} ${isHero ? 'hero' : ''}`}
className={`video-tile ${isTalking ? "talking" : ""} ${isHero ? "hero" : ""}`}
>
{showStream ? (
<>
<video
ref={videoRef}
autoPlay
playsInline
muted={isMuted}
/>
<video ref={videoRef} autoPlay playsInline muted={isMuted} />
<div className="video-controls">
{!isLocal && (
<button className="watch-btn active" onClick={(e) => { e.stopPropagation(); onToggleWatch?.(); }} title="Stop Watching">
<button
className="watch-btn active"
onClick={(e) => {
e.stopPropagation();
onToggleWatch?.();
}}
title="Stop Watching"
>
Stop Watching
</button>
)}
<button className="fullscreen-btn" onClick={handleFullscreen} title="Toggle Fullscreen">
<button
className="fullscreen-btn"
onClick={handleFullscreen}
title="Toggle Fullscreen"
>
</button>
</div>
<div className="tile-actions-right">
{!isLocal && stream && (
stream.getAudioTracks().length > 0 ? (
<button className="mute-tile-btn" onClick={toggleMute} title={isMuted ? "Unmute" : "Mute"}>
{isMuted ? '🔈' : '🔊'}
{!isLocal &&
stream &&
(stream.getAudioTracks().length > 0 ? (
<button
className="mute-tile-btn"
onClick={toggleMute}
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? "🔈" : "🔊"}
</button>
) : (
<button className="mute-tile-btn disabled" title="Streamer is not sharing audio" style={{ cursor: 'help', opacity: 0.6 }}>
<button
className="mute-tile-btn disabled"
title="Streamer is not sharing audio"
style={{ cursor: "help", opacity: 0.6 }}
>
🔇
</button>
)
)}
))}
</div>
</>
) : (
<div className="avatar-placeholder-container">
<div className="avatar-placeholder">
{name[0].toUpperCase()}
</div>
<div className="avatar-placeholder">{name[0].toUpperCase()}</div>
{!isLocal && isSharing && (
<button className="watch-btn" onClick={(e) => { e.stopPropagation(); onToggleWatch?.(); }}>
<button
className="watch-btn"
onClick={(e) => {
e.stopPropagation();
onToggleWatch?.();
}}
>
Watch Stream
</button>
)}
</div>
)}
<div className="tile-info">
<span className="user-name">{name} {isLocal ? '(You)' : ''}</span>
<span className="user-name">
{name} {isLocal ? "(You)" : ""}
</span>
{isSharing && <span className="sharing-badge">LIVE</span>}
</div>
</div>
);
};
export const VideoGrid: React.FC<VideoGridProps> = ({
peers,
localScreenStream,
connectedChannelId,
startWatching,
stopWatching,
watching
watching,
}) => {
const { identity: localIdentity } = useSpacetimeDB();
const [voiceStates] = useTable(tables.voice_state);
const [users] = useTable(tables.user);
const [focusedIdentity, setFocusedIdentity] = React.useState<Identity | null>(null);
const [focusedIdentity, setFocusedIdentity] = React.useState<Identity | null>(
null,
);
const participants = voiceStates.filter(vs => vs.channelId === connectedChannelId);
const participants = voiceStates.filter(
(vs) => vs.channelId === connectedChannelId,
);
const isWatchingPeer = (peerIdHex: string) => {
return watching.some(w =>
w.watcher.isEqual(localIdentity!) && w.watchee.toHexString() === peerIdHex
return watching.some(
(w) =>
w.watcher.isEqual(localIdentity!) &&
w.watchee.toHexString() === peerIdHex,
);
};
@@ -170,7 +199,7 @@ export const VideoGrid: React.FC<VideoGridProps> = ({
};
const localSharing = !!localScreenStream;
const remoteSharerVs = participants.find(vs => {
const remoteSharerVs = participants.find((vs) => {
if (vs.identity.isEqual(localIdentity!)) return false;
return vs.isSharingScreen;
});
@@ -185,15 +214,15 @@ export const VideoGrid: React.FC<VideoGridProps> = ({
const isLocal = vs.identity.isEqual(localIdentity!);
const peerIdHex = vs.identity.toHexString();
const peer = peers.get(peerIdHex);
const user = users.find(u => u.identity.isEqual(vs.identity));
const user = users.find((u) => u.identity.isEqual(vs.identity));
const isHero = primarySharerIdentity?.isEqual(vs.identity);
return (
<div
key={peerIdHex}
className={`video-tile-container ${isHero ? 'is-hero' : 'is-row'}`}
className={`video-tile-container ${isHero ? "is-hero" : "is-row"}`}
onClick={() => setFocusedIdentity(vs.identity)}
style={{ cursor: 'pointer' }}
style={{ cursor: "pointer" }}
>
<VideoTile
identity={vs.identity}
@@ -209,23 +238,27 @@ export const VideoGrid: React.FC<VideoGridProps> = ({
);
};
const heroVs = participants.find(vs => vs.identity.isEqual(primarySharerIdentity || Identity.zero()));
const rowParticipants = participants.filter(vs => !vs.identity.isEqual(primarySharerIdentity || Identity.zero()));
const heroVs = participants.find((vs) =>
vs.identity.isEqual(primarySharerIdentity || Identity.zero()),
);
const rowParticipants = participants.filter(
(vs) => !vs.identity.isEqual(primarySharerIdentity || Identity.zero()),
);
return (
<div className={`video-grid ${primarySharerIdentity ? 'has-sharer' : ''}`}>
<div className={`video-grid ${primarySharerIdentity ? "has-sharer" : ""}`}>
<div className="video-grid-content">
{primarySharerIdentity ? (
<>
{heroVs && renderTile(heroVs)}
{rowParticipants.length > 0 && (
<div className="video-participants-row">
{rowParticipants.map(vs => renderTile(vs))}
{rowParticipants.map((vs) => renderTile(vs))}
</div>
)}
</>
) : (
participants.map(vs => renderTile(vs))
participants.map((vs) => renderTile(vs))
)}
</div>
</div>
+2 -2
View File
@@ -1,3 +1,3 @@
// src/chat/index.ts
export { default as ChatContainer } from './ChatContainer';
export { useChat } from './services/useChat';
export { default as ChatContainer } from "./ChatContainer";
export { useChat } from "./services/useChat";
+284 -114
View File
@@ -1,24 +1,30 @@
// src/chat/services/useChat.ts
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useTable, useReducer, useSpacetimeDB } from 'spacetimedb/react';
import { Identity } from 'spacetimedb';
import * as Types from '../../module_bindings/types';
import React, {
useState,
useMemo,
useEffect,
useCallback,
useRef,
} from "react";
import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react";
import { Identity } from "spacetimedb";
import * as Types from "../../module_bindings/types";
// Import tables and reducers from module_bindings
import { tables, reducers } from '../../module_bindings';
import { tables, reducers } from "../../module_bindings";
// Import the useAuth hook (assuming it's defined and exported from src/auth/index.ts or similar)
import { useAuth } from "react-oidc-context";
import { TOKEN_KEY } from '../../main.tsx'; // Import TOKEN_KEY for auth status
import { TOKEN_KEY } from "../../main.tsx"; // Import TOKEN_KEY for auth status
// Helper functions (extracted from App.tsx)
const getUsername = (userIdentity: Identity | null, users: Types.User[]) => {
if (!userIdentity) return "Unknown";
const user = users.find(u => u.identity?.isEqual(userIdentity));
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
const formatTime = (ts: any) => {
const date = new Date(Number(ts.microsSinceUnixEpoch / 1000n));
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
// Define interface for the hook's return value
@@ -62,9 +68,21 @@ interface ChatState {
voiceChannels: readonly Types.Channel[];
createServerReducer: (params: { name: string }) => void;
createChannelReducer: (params: { serverId: bigint, name: string, isVoice: boolean }) => void;
createThreadReducer: (params: { name: string, channelId: bigint, parentMessageId: bigint }) => void;
sendMessageReducer: (params: { channelId: bigint, text: string, threadId: bigint | undefined }) => void;
createChannelReducer: (params: {
serverId: bigint;
name: string;
isVoice: boolean;
}) => void;
createThreadReducer: (params: {
name: string;
channelId: bigint;
parentMessageId: bigint;
}) => void;
sendMessageReducer: (params: {
channelId: bigint;
text: string;
threadId: bigint | undefined;
}) => void;
joinVoiceReducer: (params: { channelId: bigint }) => void;
leaveVoiceReducer: () => void;
setNameReducer: (params: { name: string }) => void;
@@ -110,17 +128,17 @@ export function useChat(): ChatState {
const [activeServerId, setActiveServerId] = useState<bigint | null>(null);
const [activeChannelId, setActiveChannelId] = useState<bigint | null>(null);
const [activeThreadId, setActiveThreadId] = useState<bigint | null>(null);
const [messageText, setMessageText] = useState('');
const [threadMessageText, setThreadMessageText] = useState('');
const [messageText, setMessageText] = useState("");
const [threadMessageText, setThreadMessageText] = useState("");
const [showCreateServerModal, setShowCreateServerModal] = useState(false);
const [newServerName, setNewServerName] = useState('');
const [newServerName, setNewServerName] = useState("");
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
const [newChannelName, setNewChannelName] = useState('');
const [newChannelName, setNewChannelName] = useState("");
const [isVoiceChannel, setIsVoiceChannel] = useState(false);
const [showSetNameModal, setShowSetNameModal] = useState(false);
const [newName, setNewName] = useState('');
const [newName, setNewName] = useState("");
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
const [authError, setAuthError] = useState(''); // Consider if this should be part of auth hook
const [authError, setAuthError] = useState(""); // Consider if this should be part of auth hook
// Fetching data from tables
const [servers] = useTable(useMemo(() => tables.server, []));
@@ -132,15 +150,25 @@ export function useChat(): ChatState {
const [voiceStates] = useTable(useMemo(() => tables.voice_state, []));
// Reducers
const createServerReducer = useReducer(useMemo(() => reducers.createServer, []));
const createChannelReducer = useReducer(useMemo(() => reducers.createChannel, []));
const createThreadReducer = useReducer(useMemo(() => reducers.createThread, []));
const sendMessageReducer = useReducer(useMemo(() => reducers.sendMessage, []));
const createServerReducer = useReducer(
useMemo(() => reducers.createServer, []),
);
const createChannelReducer = useReducer(
useMemo(() => reducers.createChannel, []),
);
const createThreadReducer = useReducer(
useMemo(() => reducers.createThread, []),
);
const sendMessageReducer = useReducer(
useMemo(() => reducers.sendMessage, []),
);
const joinVoiceReducer = useReducer(useMemo(() => reducers.joinVoice, []));
const leaveVoiceReducer = useReducer(useMemo(() => reducers.leaveVoice, []));
const setNameReducer = useReducer(useMemo(() => reducers.setName, []));
const joinServerReducer = useReducer(useMemo(() => reducers.joinServer, []));
const leaveServerReducer = useReducer(useMemo(() => reducers.leaveServer, []));
const leaveServerReducer = useReducer(
useMemo(() => reducers.leaveServer, []),
);
// Get current identity from SpacetimeDB
const { identity } = useSpacetimeDB();
@@ -149,39 +177,49 @@ export function useChat(): ChatState {
const isFullyAuthenticated = useMemo(() => {
// If we have an identity, we are at least partially authenticated (guest)
// but the app logic seems to want OIDC or username/password for "full" auth
const user = users.find(u => u.identity?.isEqual(identity || Identity.zero()));
const user = users.find((u) =>
u.identity?.isEqual(identity || Identity.zero()),
);
if (!user) return false;
const hasOidc = !!(user.issuer && user.subject);
const hasCreds = !!(user.username && user.password);
console.log('useChat: isFullyAuthenticated check - hasOidc:', hasOidc, 'hasCreds:', hasCreds);
console.log(
"useChat: isFullyAuthenticated check - hasOidc:",
hasOidc,
"hasCreds:",
hasCreds,
);
return hasOidc || hasCreds;
}, [users, identity]);
// Logging fetched data and auth status
console.log('useChat: servers:', servers?.length);
console.log('useChat: channels:', channels?.length);
console.log('useChat: users:', users?.length, 'isUsersReady:', isUsersReady);
console.log('useChat: messages:', allMessages?.length);
console.log('useChat: threads:', allThreads?.length);
console.log('useChat: voiceStates:', voiceStates?.length);
console.log('useChat: auth.isAuthenticated:', auth.isAuthenticated);
console.log('useChat: auth.user:', auth.user);
console.log('useChat: identity:', identity?.toHexString());
console.log('useChat: isFullyAuthenticated:', isFullyAuthenticated);
console.log("useChat: servers:", servers?.length);
console.log("useChat: channels:", channels?.length);
console.log("useChat: users:", users?.length, "isUsersReady:", isUsersReady);
console.log("useChat: messages:", allMessages?.length);
console.log("useChat: threads:", allThreads?.length);
console.log("useChat: voiceStates:", voiceStates?.length);
console.log("useChat: auth.isAuthenticated:", auth.isAuthenticated);
console.log("useChat: auth.user:", auth.user);
console.log("useChat: identity:", identity?.toHexString());
console.log("useChat: isFullyAuthenticated:", isFullyAuthenticated);
// Initialization logic for active server/channel
const joinedServerIds = useMemo(() => {
if (!identity) return new Set<bigint>();
return new Set(serverMembers.filter(m => m.identity.isEqual(identity)).map(m => m.serverId));
return new Set(
serverMembers
.filter((m) => m.identity.isEqual(identity))
.map((m) => m.serverId),
);
}, [serverMembers, identity]);
const joinedServers = useMemo(() => {
return servers.filter(s => joinedServerIds.has(s.id));
return servers.filter((s) => joinedServerIds.has(s.id));
}, [servers, joinedServerIds]);
const availableServers = useMemo(() => {
return servers.filter(s => !joinedServerIds.has(s.id));
return servers.filter((s) => !joinedServerIds.has(s.id));
}, [servers, joinedServerIds]);
useEffect(() => {
@@ -192,9 +230,16 @@ export function useChat(): ChatState {
useEffect(() => {
if (activeServerId) {
const serverChannels = channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Text');
const serverChannels = channels.filter(
(c) => c.serverId === activeServerId && c.kind.tag === "Text",
);
if (serverChannels.length > 0) {
if (!activeChannelId || !channels.some(c => c.id === activeChannelId && c.serverId === activeServerId)) {
if (
!activeChannelId ||
!channels.some(
(c) => c.id === activeChannelId && c.serverId === activeServerId,
)
) {
setActiveChannelId(serverChannels[0].id);
}
} else {
@@ -204,121 +249,178 @@ export function useChat(): ChatState {
}, [activeServerId, channels, activeChannelId]);
// Derived Data
const activeServer = useMemo(() =>
servers.find(s => s.id === activeServerId),
[servers, activeServerId]
const activeServer = useMemo(
() => servers.find((s) => s.id === activeServerId),
[servers, activeServerId],
);
const activeChannel = useMemo(() =>
channels.find(c => c.id === activeChannelId),
[channels, activeChannelId]
const activeChannel = useMemo(
() => channels.find((c) => c.id === activeChannelId),
[channels, activeChannelId],
);
const activeThread = useMemo(() =>
allThreads.find(t => t.id === activeThreadId),
[allThreads, activeThreadId]
const activeThread = useMemo(
() => allThreads.find((t) => t.id === activeThreadId),
[allThreads, activeThreadId],
);
const isActiveChannelVoice = useMemo(() => activeChannel?.kind.tag === 'Voice', [activeChannel]);
const isActiveChannelText = useMemo(() => activeChannel?.kind.tag === 'Text', [activeChannel]);
const isActiveChannelVoice = useMemo(
() => activeChannel?.kind.tag === "Voice",
[activeChannel],
);
const isActiveChannelText = useMemo(
() => activeChannel?.kind.tag === "Text",
[activeChannel],
);
const textChannels = useMemo(() => {
if (!activeServerId) return [];
return channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Text');
return channels.filter(
(c) => c.serverId === activeServerId && c.kind.tag === "Text",
);
}, [channels, activeServerId]);
const voiceChannels = useMemo(() => {
if (!activeServerId) return [];
return channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Voice');
return channels.filter(
(c) => c.serverId === activeServerId && c.kind.tag === "Voice",
);
}, [channels, activeServerId]);
const channelMessages = useMemo(() => {
if (!activeChannelId) return [];
return allMessages
.filter(m => m.channelId === activeChannelId && m.threadId === undefined)
.sort((a, b) => a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1);
.filter(
(m) => m.channelId === activeChannelId && m.threadId === undefined,
)
.sort((a, b) =>
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1,
);
}, [allMessages, activeChannelId]);
const threadMessages = useMemo(() => {
if (!activeThreadId) return [];
return allMessages
.filter(m => m.threadId === activeThreadId)
.sort((a, b) => a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1);
.filter((m) => m.threadId === activeThreadId)
.sort((a, b) =>
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1,
);
}, [allMessages, activeThreadId]);
// Updated to use identity from useSpacetimeDB
const currentUser = useMemo(() =>
users.find(u => u.identity?.isEqual(identity || Identity.zero())),
[users, identity]
const currentUser = useMemo(
() => users.find((u) => u.identity?.isEqual(identity || Identity.zero())),
[users, identity],
);
// Updated to use identity from useSpacetimeDB
const currentVoiceState = useMemo(() =>
voiceStates.find(vs => vs.identity?.isEqual(identity || Identity.zero())),
[voiceStates, identity]
const currentVoiceState = useMemo(
() =>
voiceStates.find((vs) =>
vs.identity?.isEqual(identity || Identity.zero()),
),
[voiceStates, identity],
);
const connectedVoiceChannel = useMemo(() =>
channels.find(c => c.id === currentVoiceState?.channelId),
[channels, currentVoiceState]
const connectedVoiceChannel = useMemo(
() => channels.find((c) => c.id === currentVoiceState?.channelId),
[channels, currentVoiceState],
);
// Updated to use identity from useSpacetimeDB
const onlineUsers = useMemo(() =>
users.filter(u => u.online || (identity?.isEqual(u.identity))),
[users, identity]
const onlineUsers = useMemo(
() => users.filter((u) => u.online || identity?.isEqual(u.identity)),
[users, identity],
);
// Check if user has linked OIDC or username/password credentials
// (Moved isFullyAuthenticated up earlier in the file already)
// Event Handlers
const handleSendMessage = useCallback((e: React.FormEvent) => {
const handleSendMessage = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!messageText.trim() || !activeChannelId) return;
sendMessageReducer({ text: messageText, channelId: activeChannelId, threadId: undefined });
setMessageText('');
}, [messageText, activeChannelId, sendMessageReducer]);
sendMessageReducer({
text: messageText,
channelId: activeChannelId,
threadId: undefined,
});
setMessageText("");
},
[messageText, activeChannelId, sendMessageReducer],
);
const handleSendThreadMessage = useCallback((e: React.FormEvent) => {
const handleSendThreadMessage = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!threadMessageText.trim() || !activeThreadId || !activeChannelId) return;
sendMessageReducer({ text: threadMessageText, channelId: activeChannelId, threadId: activeThreadId });
setThreadMessageText('');
}, [threadMessageText, activeThreadId, activeChannelId, sendMessageReducer]);
if (!threadMessageText.trim() || !activeThreadId || !activeChannelId)
return;
sendMessageReducer({
text: threadMessageText,
channelId: activeChannelId,
threadId: activeThreadId,
});
setThreadMessageText("");
},
[threadMessageText, activeThreadId, activeChannelId, sendMessageReducer],
);
const handleCreateServer = useCallback((e: React.FormEvent) => {
const handleCreateServer = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!newServerName.trim()) return;
createServerReducer({ name: newServerName });
setNewServerName('');
setNewServerName("");
setShowCreateServerModal(false);
}, [newServerName, createServerReducer]);
},
[newServerName, createServerReducer],
);
const handleCreateChannel = useCallback((e: React.FormEvent) => {
const handleCreateChannel = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!newChannelName.trim() || !activeServerId) return;
createChannelReducer({ name: newChannelName, serverId: activeServerId, isVoice: isVoiceChannel });
setNewChannelName('');
createChannelReducer({
name: newChannelName,
serverId: activeServerId,
isVoice: isVoiceChannel,
});
setNewChannelName("");
setIsVoiceChannel(false);
setShowCreateChannelModal(false);
}, [newChannelName, activeServerId, isVoiceChannel, createChannelReducer]);
},
[newChannelName, activeServerId, isVoiceChannel, createChannelReducer],
);
const handleSetName = useCallback((e: React.FormEvent) => {
const handleSetName = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setNameReducer({ name: newName });
setShowSetNameModal(false);
}, [newName, setNameReducer]);
},
[newName, setNameReducer],
);
const handleStartThread = useCallback((msg: Types.Message) => {
const handleStartThread = useCallback(
(msg: Types.Message) => {
const threadName = `Thread on: ${msg.text.substring(0, 20)}...`;
createThreadReducer({ name: threadName, channelId: msg.channelId, parentMessageId: msg.id });
}, [createThreadReducer]);
createThreadReducer({
name: threadName,
channelId: msg.channelId,
parentMessageId: msg.id,
});
},
[createThreadReducer],
);
const handleJoinVoice = useCallback((channelId: bigint) => {
const handleJoinVoice = useCallback(
(channelId: bigint) => {
joinVoiceReducer({ channelId });
}, [joinVoiceReducer]);
},
[joinVoiceReducer],
);
const handleLeaveVoice = useCallback(() => {
if (currentVoiceState) {
@@ -326,55 +428,123 @@ export function useChat(): ChatState {
}
}, [currentVoiceState, leaveVoiceReducer]);
const handleJoinServer = useCallback((serverId: bigint) => {
const handleJoinServer = useCallback(
(serverId: bigint) => {
joinServerReducer({ serverId });
setShowDiscoveryModal(false);
}, [joinServerReducer]);
},
[joinServerReducer],
);
const handleLeaveServer = useCallback((serverId: bigint) => {
const handleLeaveServer = useCallback(
(serverId: bigint) => {
leaveServerReducer({ serverId });
if (activeServerId === serverId) {
setActiveServerId(null);
}
}, [activeServerId, leaveServerReducer]);
},
[activeServerId, leaveServerReducer],
);
return {
// State variables
activeServerId, activeChannelId, activeThreadId, messageText, threadMessageText,
showCreateServerModal, newServerName, showCreateChannelModal, newChannelName,
isVoiceChannel, showSetNameModal, newName, showDiscoveryModal, authError,
activeServerId,
activeChannelId,
activeThreadId,
messageText,
threadMessageText,
showCreateServerModal,
newServerName,
showCreateChannelModal,
newChannelName,
isVoiceChannel,
showSetNameModal,
newName,
showDiscoveryModal,
authError,
// Data fetched from tables
servers, joinedServers, availableServers, channels, users, allMessages, allThreads, voiceStates,
currentVoiceState, connectedVoiceChannel, onlineUsers,
servers,
joinedServers,
availableServers,
channels,
users,
allMessages,
allThreads,
voiceStates,
currentVoiceState,
connectedVoiceChannel,
onlineUsers,
activeServerMembers: useMemo(() => {
if (!activeServerId) return [];
const memberIdentities = new Set(serverMembers.filter(m => m.serverId === activeServerId).map(m => m.identity.toHexString()));
return users.filter(u => memberIdentities.has(u.identity.toHexString()));
const memberIdentities = new Set(
serverMembers
.filter((m) => m.serverId === activeServerId)
.map((m) => m.identity.toHexString()),
);
return users.filter((u) =>
memberIdentities.has(u.identity.toHexString()),
);
}, [serverMembers, users, activeServerId]),
currentUser,
activeServer, activeChannel, activeThread, isActiveChannelVoice, isActiveChannelText, channelMessages, threadMessages,
textChannels, voiceChannels,
activeServer,
activeChannel,
activeThread,
isActiveChannelVoice,
isActiveChannelText,
channelMessages,
threadMessages,
textChannels,
voiceChannels,
// Reducers
createServerReducer, createChannelReducer, createThreadReducer, sendMessageReducer,
joinVoiceReducer, leaveVoiceReducer, setNameReducer, joinServerReducer, leaveServerReducer,
createServerReducer,
createChannelReducer,
createThreadReducer,
sendMessageReducer,
joinVoiceReducer,
leaveVoiceReducer,
setNameReducer,
joinServerReducer,
leaveServerReducer,
// State setters
setActiveServerId, setActiveChannelId, setActiveThreadId, setMessageText,
setThreadMessageText, setShowCreateServerModal, setNewServerName, setShowCreateChannelModal,
setNewChannelName, setIsVoiceChannel, setShowSetNameModal, setNewName, setShowDiscoveryModal, setAuthError,
setActiveServerId,
setActiveChannelId,
setActiveThreadId,
setMessageText,
setThreadMessageText,
setShowCreateServerModal,
setNewServerName,
setShowCreateChannelModal,
setNewChannelName,
setIsVoiceChannel,
setShowSetNameModal,
setNewName,
setShowDiscoveryModal,
setAuthError,
// Event handlers
handleSendMessage, handleSendThreadMessage, handleCreateServer, handleCreateChannel,
handleStartThread, handleJoinVoice, handleLeaveVoice, handleSetName,
handleJoinServer, handleLeaveServer,
handleSendMessage,
handleSendThreadMessage,
handleCreateServer,
handleCreateChannel,
handleStartThread,
handleJoinVoice,
handleLeaveVoice,
handleSetName,
handleJoinServer,
handleLeaveServer,
// Derived status
isFullyAuthenticated,
// Helper functions
getUsername: useCallback((userIdentity: Identity | null) => getUsername(userIdentity, users as Types.User[]), [users]),
getUsername: useCallback(
(userIdentity: Identity | null) =>
getUsername(userIdentity, users as Types.User[]),
[users],
),
formatTime: useCallback((ts: any) => formatTime(ts), []),
};
}
@@ -0,0 +1,226 @@
import { useEffect, useCallback, useMemo, useRef } from "react";
import { Identity } from "spacetimedb";
import { useTable, useReducer } from "spacetimedb/react";
import { tables, reducers } from "../../../module_bindings";
import { usePeerManager } from "./usePeerManager";
export const useChannelAudioWebRTC = (
connectedChannelId: bigint | undefined,
identity: Identity | null,
localStream: MediaStream | null,
isDeafened: boolean
) => {
const [voiceStates] = useTable(tables.voice_state);
const [offers] = useTable(tables.voice_sdp_offer);
const [answers] = useTable(tables.voice_sdp_answer);
const [iceCandidates] = useTable(tables.voice_ice_candidate);
const sendSdpOffer = useReducer(reducers.sendVoiceSdpOffer);
const sendSdpAnswer = useReducer(reducers.sendVoiceSdpAnswer);
const sendIceCandidate = useReducer(reducers.sendVoiceIceCandidate);
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
const ignoreOfferRef = useRef<Map<string, boolean>>(new Map());
const processedOffersRef = useRef<Set<bigint>>(new Set());
const processedAnswersRef = useRef<Set<bigint>>(new Set());
const processedCandidatesRef = useRef<Set<bigint>>(new Set());
const candidateQueueRef = useRef<Map<string, any[]>>(new Map());
const connectedChannelIdRef = useRef(connectedChannelId);
useEffect(() => { connectedChannelIdRef.current = connectedChannelId; }, [connectedChannelId]);
const drainCandidateQueue = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
const queue = candidateQueueRef.current.get(peerIdHex) || [];
if (queue.length === 0 || !pc.remoteDescription) return;
console.log(`[WebRTC][voice] Draining ${queue.length} candidates for ${peerIdHex}`);
for (const cand of queue) {
try { await pc.addIceCandidate(new RTCIceCandidate(cand)); }
catch (e) { console.warn(`[WebRTC][voice] Error adding queued ICE for ${peerIdHex}`, e); }
}
candidateQueueRef.current.set(peerIdHex, []);
}, []);
const onNegotiationNeeded = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
const channelId = connectedChannelIdRef.current;
if (!channelId || pc.signalingState !== 'stable' || makingOfferRef.current.get(peerIdHex)) {
console.log(`[WebRTC][voice] Skipping negotiation for ${peerIdHex}: state=${pc.signalingState}, makingOffer=${makingOfferRef.current.get(peerIdHex)}`);
return;
}
try {
makingOfferRef.current.set(peerIdHex, true);
console.log(`[WebRTC][voice] Creating offer for ${peerIdHex}...`);
await pc.setLocalDescription();
sendSdpOffer({
receiver: Identity.fromString(peerIdHex),
sdp: JSON.stringify(pc.localDescription),
channelId
});
} catch (e) { console.error(`[WebRTC][voice] Negotiation error for ${peerIdHex}`, e); }
finally { makingOfferRef.current.set(peerIdHex, false); }
}, [sendSdpOffer]);
const onIceCandidate = useCallback((peerIdHex: string, candidate: RTCIceCandidate) => {
const channelId = connectedChannelIdRef.current;
if (channelId) {
sendIceCandidate({
receiver: Identity.fromString(peerIdHex),
candidate: JSON.stringify(candidate),
channelId
});
}
}, [sendIceCandidate]);
const peerManager = usePeerManager(
identity,
"voice",
isDeafened,
onNegotiationNeeded,
onIceCandidate
);
// Handle Incoming Signaling
useEffect(() => {
if (!connectedChannelId || !identity) return;
// Offers
const myOffers = offers.filter(o => o.receiver.isEqual(identity) && !o.sender.isEqual(identity) && o.channelId === connectedChannelId);
(async () => {
for (const offerRow of myOffers) {
if (processedOffersRef.current.has(offerRow.id)) continue;
processedOffersRef.current.add(offerRow.id);
const peerIdHex = offerRow.sender.toHexString();
console.log(`[WebRTC][voice] Received offer from ${peerIdHex}`);
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) continue;
try {
const isPolite = identity.toHexString() < peerIdHex;
const makingOffer = makingOfferRef.current.get(peerIdHex) || false;
const offerCollision = (pc.signalingState !== "stable") || makingOffer;
const ignoreOffer = !isPolite && offerCollision;
ignoreOfferRef.current.set(peerIdHex, ignoreOffer);
if (ignoreOffer) {
console.log(`[WebRTC][voice] Ignoring offer collision from ${peerIdHex} (impolite)`);
continue;
}
if (offerCollision) {
console.log(`[WebRTC][voice] Handling offer collision from ${peerIdHex} (polite), rolling back...`);
await pc.setLocalDescription({ type: "rollback" as RTCSdpType });
}
console.log(`[WebRTC][voice] Setting remote description from ${peerIdHex}`);
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(offerRow.sdp)));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
console.log(`[WebRTC][voice] Sending answer to ${peerIdHex}`);
sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId });
await drainCandidateQueue(peerIdHex, pc);
} catch (e) { console.error(`[WebRTC][voice] Error handling offer from ${peerIdHex}`, e); }
}
})();
// Answers
const myAnswers = answers.filter(a => a.receiver.isEqual(identity) && !a.sender.isEqual(identity) && a.channelId === connectedChannelId);
(async () => {
for (const answerRow of myAnswers) {
if (processedAnswersRef.current.has(answerRow.id)) continue;
processedAnswersRef.current.add(answerRow.id);
const peerIdHex = answerRow.sender.toHexString();
const peer = peerManager.getPeer(peerIdHex);
if (peer) {
try {
console.log(`[WebRTC][voice] Received answer from ${peerIdHex}, setting remote description`);
await peer.pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(answerRow.sdp)));
await drainCandidateQueue(peerIdHex, peer.pc);
} catch (e) { console.error(`[WebRTC][voice] Error handling answer from ${peerIdHex}`, e); }
}
}
})();
// ICE Candidates
const myCandidates = iceCandidates.filter(c => c.receiver.isEqual(identity) && !c.sender.isEqual(identity) && c.channelId === connectedChannelId);
(async () => {
for (const candRow of myCandidates) {
if (processedCandidatesRef.current.has(candRow.id)) continue;
processedCandidatesRef.current.add(candRow.id);
const peerIdHex = candRow.sender.toHexString();
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) continue;
try {
const candidate = JSON.parse(candRow.candidate);
const ignoreOffer = ignoreOfferRef.current.get(peerIdHex) || false;
if (pc.remoteDescription) {
console.log(`[WebRTC][voice] Adding ICE candidate from ${peerIdHex}`);
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else if (!ignoreOffer) {
console.log(`[WebRTC][voice] Queueing ICE candidate from ${peerIdHex}`);
const queue = candidateQueueRef.current.get(peerIdHex) || [];
queue.push(candidate);
candidateQueueRef.current.set(peerIdHex, queue);
}
} catch (e) { console.error(`[WebRTC][voice] Error handling ICE from ${peerIdHex}`, e); }
}
})();
}, [offers, answers, iceCandidates, connectedChannelId, identity, peerManager, sendSdpAnswer, drainCandidateQueue]);
// Track Syncing
useEffect(() => {
const audioTrack = localStream?.getAudioTracks()[0] || null;
peerManager.peersRef.current.forEach(async (peer, peerIdHex) => {
const transceivers = peer.pc.getTransceivers();
if (transceivers[0] && transceivers[0].sender.track !== audioTrack) {
try {
console.log(`[WebRTC][voice] Syncing audio track for ${peerIdHex}`);
await transceivers[0].sender.replaceTrack(audioTrack);
if (peer.pc.signalingState === 'stable') onNegotiationNeeded(peerIdHex, peer.pc);
} catch (e) { console.error(`[WebRTC][voice] Error syncing track for ${peerIdHex}`, e); }
}
});
}, [localStream, peerManager.peers, onNegotiationNeeded, peerManager.peersRef]);
// Lifecycle
const voicePeersToConnect = useMemo(() => {
if (!identity || !connectedChannelId) return new Set<string>();
return new Set(voiceStates
.filter(vs => vs.channelId === connectedChannelId && !vs.identity.isEqual(identity))
.map(vs => vs.identity.toHexString()));
}, [voiceStates, identity, connectedChannelId]);
useEffect(() => {
if (!connectedChannelId || !identity) {
console.log(`[WebRTC][voice] Cleaning up connections (channel=${connectedChannelId}, identity=${!!identity})`);
peerManager.peersRef.current.forEach((_, id) => peerManager.closePeer(id));
processedOffersRef.current.clear();
processedAnswersRef.current.clear();
processedCandidatesRef.current.clear();
return;
}
voicePeersToConnect.forEach(id => {
if (!peerManager.peersRef.current.has(id)) {
console.log(`[WebRTC][voice] Proactively connecting to ${id}`);
peerManager.createPeerConnection(id, [localStream?.getAudioTracks()[0] || null]);
}
});
peerManager.peersRef.current.forEach((_, id) => {
if (!voicePeersToConnect.has(id)) {
console.log(`[WebRTC][voice] Peer ${id} no longer in channel, closing`);
peerManager.closePeer(id);
}
});
}, [voicePeersToConnect, connectedChannelId, identity, peerManager, localStream]);
return {
peerStatuses: peerManager.peerStatuses,
peerStats: peerManager.peerStats,
peers: peerManager.peers
};
};
+30 -14
View File
@@ -4,7 +4,8 @@ import { reducers } from "../../../module_bindings";
export const useLocalMedia = () => {
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [localScreenStream, setLocalScreenStream] = useState<MediaStream | null>(null);
const [localScreenStream, setLocalScreenStream] =
useState<MediaStream | null>(null);
const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
const [isTalking, setIsTalking] = useState(false);
@@ -16,14 +17,17 @@ export const useLocalMedia = () => {
const setTalking = useReducer(reducers.setTalking);
const setSharingScreen = useReducer(reducers.setSharingScreen);
const toggleMute = useCallback(() => setIsMuted(prev => !prev), []);
const toggleDeafen = useCallback(() => setIsDeafened(prev => !prev), []);
const toggleMute = useCallback(() => setIsMuted((prev) => !prev), []);
const toggleDeafen = useCallback(() => setIsDeafened((prev) => !prev), []);
const requestMic = useCallback(async () => {
if (localStreamRef.current) return;
try {
console.log("[WebRTC] Requesting mic permission...");
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
setLocalStream(stream);
localStreamRef.current = stream;
} catch (err) {
@@ -33,16 +37,20 @@ export const useLocalMedia = () => {
const releaseMic = useCallback(() => {
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
localStreamRef.current.getTracks().forEach((track) => track.stop());
setLocalStream(null);
localStreamRef.current = null;
}
}, []);
const startScreenShare = useCallback(async (onTrackReady: (track: MediaStreamTrack) => void) => {
const startScreenShare = useCallback(
async (onTrackReady: (track: MediaStreamTrack) => void) => {
try {
console.log("[WebRTC] Requesting screen share...");
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
});
setLocalScreenStream(stream);
localScreenStreamRef.current = stream;
setSharingScreen({ sharing: true });
@@ -50,27 +58,35 @@ export const useLocalMedia = () => {
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack) {
onTrackReady(videoTrack);
videoTrack.onended = () => stopScreenShare(() => onTrackReady(null as any));
videoTrack.onended = () =>
stopScreenShare(() => onTrackReady(null as any));
}
} catch (err) {
console.error("[WebRTC] Failed to start screen share:", err);
}
}, [setSharingScreen]);
},
[setSharingScreen],
);
const stopScreenShare = useCallback((onTrackCleared: (track: MediaStreamTrack | null) => void) => {
const stopScreenShare = useCallback(
(onTrackCleared: (track: MediaStreamTrack | null) => void) => {
if (localScreenStreamRef.current) {
localScreenStreamRef.current.getTracks().forEach(track => track.stop());
localScreenStreamRef.current
.getTracks()
.forEach((track) => track.stop());
setLocalScreenStream(null);
localScreenStreamRef.current = null;
setSharingScreen({ sharing: false });
onTrackCleared(null);
}
}, [setSharingScreen]);
},
[setSharingScreen],
);
// Handle Mute/Deafen effect on tracks
useEffect(() => {
if (localStreamRef.current) {
localStreamRef.current.getAudioTracks().forEach(track => {
localStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = !isMuted && !isDeafened;
});
}
@@ -140,6 +156,6 @@ export const useLocalMedia = () => {
requestMic,
releaseMic,
localStreamRef,
localScreenStreamRef
localScreenStreamRef,
};
};
+184 -95
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Identity } from "spacetimedb";
import { Peer, WebRTCStats } from "./types";
@@ -8,84 +8,149 @@ const ICE_SERVERS: RTCConfiguration = {
export const usePeerManager = (
identity: Identity | null,
isDeafened: boolean,
localStreamRef: React.MutableRefObject<MediaStream | null>,
localScreenStreamRef: React.MutableRefObject<MediaStream | null>,
mediaType: "voice" | "screen",
isDeafened: boolean, // Only relevant for voice
onNegotiationNeeded: (peerIdHex: string, pc: RTCPeerConnection) => void,
onIceCandidate: (peerIdHex: string, candidate: RTCIceCandidate) => void
onIceCandidate: (peerIdHex: string, candidate: RTCIceCandidate) => void,
) => {
const [peers, setPeers] = useState<Map<string, Peer>>(new Map());
const [peerStatuses, setPeerStatuses] = useState<Map<string, string>>(new Map());
const [peerStats, setPeerStats] = useState<Map<string, WebRTCStats>>(new Map());
const [peerStatuses, setPeerStatuses] = useState<Map<string, string>>(
new Map(),
);
const [peerStats, setPeerStats] = useState<Map<string, WebRTCStats>>(
new Map(),
);
const peersRef = useRef<Map<string, Peer>>(new Map());
const peerStatsRef = useRef<Map<string, WebRTCStats>>(new Map());
const getPeer = useCallback((peerIdHex: string) => peersRef.current.get(peerIdHex), []);
// Use refs for callbacks to avoid re-creating PC when UI state changes
const onNegotiationNeededRef = useRef(onNegotiationNeeded);
const onIceCandidateRef = useRef(onIceCandidate);
const isDeafenedRef = useRef(isDeafened);
const createPeerConnection = useCallback((peerIdHex: string) => {
if (peersRef.current.has(peerIdHex)) return peersRef.current.get(peerIdHex)!.pc;
useEffect(() => {
onNegotiationNeededRef.current = onNegotiationNeeded;
}, [onNegotiationNeeded]);
useEffect(() => {
onIceCandidateRef.current = onIceCandidate;
}, [onIceCandidate]);
useEffect(() => {
isDeafenedRef.current = isDeafened;
}, [isDeafened]);
const closePeer = useCallback(
(peerIdHex: string) => {
const peer = peersRef.current.get(peerIdHex);
if (peer) {
console.log(
`[WebRTC][${mediaType}] Closing peer connection for ${peerIdHex}`,
);
peer.pc.close();
if (peer.audio) {
peer.audio.pause();
peer.audio.srcObject = null;
}
peersRef.current.delete(peerIdHex);
setPeers(new Map(peersRef.current));
setPeerStatuses((prev) => {
const next = new Map(prev);
next.delete(peerIdHex);
return next;
});
}
},
[mediaType],
);
const createPeerConnection = useCallback(
(peerIdHex: string, initialTracks: (MediaStreamTrack | null)[] = []) => {
if (peersRef.current.has(peerIdHex))
return peersRef.current.get(peerIdHex)!.pc;
if (identity && peerIdHex === identity.toHexString()) {
console.warn(`[WebRTC] Attempted to create a PeerConnection to self (${peerIdHex}). Ignoring.`);
return null as any; // Should not happen with proper filtering
console.warn(
`[WebRTC][${mediaType}] Attempted to create a PeerConnection to self (${peerIdHex}). Ignoring.`,
);
return null as any;
}
console.log(`[WebRTC] Creating new PeerConnection for ${peerIdHex}`);
console.log(
`[WebRTC][${mediaType}] Creating new PeerConnection for ${peerIdHex}`,
);
const pc = new RTCPeerConnection(ICE_SERVERS);
// Bind handlers BEFORE adding transceivers to catch early negotiationneeded
pc.onnegotiationneeded = () => {
console.log(`[WebRTC] onnegotiationneeded fired for ${peerIdHex}`);
onNegotiationNeeded(peerIdHex, pc);
console.log(
`[WebRTC][${mediaType}] onnegotiationneeded fired for ${peerIdHex}`,
);
onNegotiationNeededRef.current(peerIdHex, pc);
};
pc.onicecandidate = (event) => {
if (event.candidate) {
onIceCandidate(peerIdHex, event.candidate);
onIceCandidateRef.current(peerIdHex, event.candidate);
}
};
pc.oniceconnectionstatechange = () => {
console.log(`[WebRTC] ICE state for ${peerIdHex}: ${pc.iceConnectionState}`);
setPeerStatuses(prev => {
console.log(
`[WebRTC][${mediaType}] ICE state for ${peerIdHex}: ${pc.iceConnectionState}`,
);
setPeerStatuses((prev) => {
const next = new Map(prev);
next.set(peerIdHex, pc.iceConnectionState);
return next;
});
if (pc.iceConnectionState === 'failed') {
console.log(`[WebRTC] ICE failed for ${peerIdHex}, closing peer for retry`);
if (pc.iceConnectionState === "failed") {
console.log(
`[WebRTC][${mediaType}] ICE failed for ${peerIdHex}, closing peer for retry`,
);
closePeer(peerIdHex);
}
};
pc.onconnectionstatechange = () => {
console.log(`[WebRTC] Connection state for ${peerIdHex}: ${pc.connectionState}`);
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
console.log(`[WebRTC] Connection ${pc.connectionState} for ${peerIdHex}, cleaning up`);
console.log(
`[WebRTC][${mediaType}] Connection state for ${peerIdHex}: ${pc.connectionState}`,
);
if (
pc.connectionState === "failed" ||
pc.connectionState === "closed"
) {
console.log(
`[WebRTC][${mediaType}] Connection ${pc.connectionState} for ${peerIdHex}, cleaning up`,
);
closePeer(peerIdHex);
}
};
pc.ontrack = (event) => {
console.log(`[WebRTC] Received track from ${peerIdHex}: ${event.track.kind} (id: ${event.track.id})`);
setPeers(prev => {
console.log(
`[WebRTC][${mediaType}] Received track from ${peerIdHex}: ${event.track.kind} (id: ${event.track.id})`,
);
setPeers((prev) => {
const next = new Map(prev);
const existingPeer = { ...(next.get(peerIdHex) || { pc }) };
if (event.track.kind === 'audio') {
if (event.track.kind === "audio") {
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.autoplay = true;
existingPeer.audio.muted = isDeafened;
existingPeer.audio.muted =
mediaType === "voice" ? isDeafenedRef.current : false;
}
const currentAudioStream = (existingPeer.audio.srcObject instanceof MediaStream)
const currentAudioStream =
existingPeer.audio.srcObject instanceof MediaStream
? existingPeer.audio.srcObject
: new MediaStream();
if (!currentAudioStream.getTracks().find(t => t.id === event.track.id)) {
if (
!currentAudioStream
.getTracks()
.find((t) => t.id === event.track.id)
) {
currentAudioStream.addTrack(event.track);
}
@@ -93,17 +158,41 @@ export const usePeerManager = (
existingPeer.audio.srcObject = currentAudioStream;
}
existingPeer.audio.play().catch(e => {
if (e.name !== 'AbortError') console.error(`[WebRTC] Error playing audio for ${peerIdHex}`, e);
existingPeer.audio.play().catch((e) => {
if (e.name !== "AbortError")
console.error(
`[WebRTC][${mediaType}] Error playing audio for ${peerIdHex}`,
e,
);
});
} else if (event.track.kind === 'video') {
const currentVideoStream = existingPeer.videoStream || new MediaStream();
if (!currentVideoStream.getTracks().find(t => t.id === event.track.id)) {
if (mediaType === "screen") {
const currentVideoStream =
existingPeer.videoStream || new MediaStream();
if (
!currentVideoStream
.getTracks()
.find((t) => t.id === event.track.id)
) {
currentVideoStream.addTrack(event.track);
}
// Force a new MediaStream object to trigger React re-render
existingPeer.videoStream = new MediaStream(currentVideoStream.getTracks());
existingPeer.videoStream = new MediaStream(
currentVideoStream.getTracks(),
);
}
} else if (event.track.kind === "video") {
const currentVideoStream =
existingPeer.videoStream || new MediaStream();
if (
!currentVideoStream
.getTracks()
.find((t) => t.id === event.track.id)
) {
currentVideoStream.addTrack(event.track);
}
existingPeer.videoStream = new MediaStream(
currentVideoStream.getTracks(),
);
}
next.set(peerIdHex, existingPeer);
@@ -112,62 +201,45 @@ export const usePeerManager = (
});
};
// Fixed transceivers for stability
pc.addTransceiver('audio', { direction: 'sendrecv' });
pc.addTransceiver('video', { direction: 'sendrecv' });
if (mediaType === "voice") {
pc.addTransceiver("audio", { direction: "sendrecv" });
} else {
pc.addTransceiver("video", { direction: "sendrecv" });
pc.addTransceiver("audio", { direction: "sendrecv" });
}
// Initialize transceivers with local tracks if available
const transceivers = pc.getTransceivers();
if (localStreamRef.current) {
const audioTrack = localStreamRef.current.getAudioTracks()[0];
if (audioTrack) {
console.log(`[WebRTC] Attaching local audio track to new connection for ${peerIdHex}`);
transceivers[0].sender.replaceTrack(audioTrack);
}
}
if (localScreenStreamRef.current) {
const videoTrack = localScreenStreamRef.current.getVideoTracks()[0];
if (videoTrack) {
console.log(`[WebRTC] Attaching local video track to new connection for ${peerIdHex}`);
transceivers[1].sender.replaceTrack(videoTrack);
}
initialTracks.forEach((track, i) => {
if (track && transceivers[i]) {
console.log(
`[WebRTC][${mediaType}] Attaching initial track ${i} to ${peerIdHex}`,
);
transceivers[i].sender.replaceTrack(track);
}
});
peersRef.current.set(peerIdHex, { pc });
setPeers(new Map(peersRef.current));
return pc;
}, [localStreamRef, localScreenStreamRef, onNegotiationNeeded, onIceCandidate]);
},
[identity, mediaType, closePeer],
);
const getPeer = useCallback(
(peerIdHex: string) => peersRef.current.get(peerIdHex),
[],
);
// Sync isDeafened state to all peer audio elements
useEffect(() => {
peersRef.current.forEach(peer => {
if (mediaType === "voice") {
peersRef.current.forEach((peer) => {
if (peer.audio) {
peer.audio.muted = isDeafened;
}
});
}, [isDeafened]);
const closePeer = useCallback((peerIdHex: string) => {
const peer = peersRef.current.get(peerIdHex);
if (peer) {
peer.pc.close();
if (peer.audio) {
peer.audio.pause();
peer.audio.srcObject = null;
}
peersRef.current.delete(peerIdHex);
setPeers(new Map(peersRef.current));
setPeerStatuses(prev => {
const next = new Map(prev);
next.delete(peerIdHex);
return next;
});
}
}, []);
}, [isDeafened, mediaType]);
// Stats Polling
useEffect(() => {
if (peers.size === 0) {
if (peerStatsRef.current.size > 0) {
@@ -185,31 +257,42 @@ export const usePeerManager = (
const prevStats = peerStatsRef.current.get(peerIdHex);
const currentStats: WebRTCStats = {
audio: { bytesReceived: 0, jitter: 0, packetsLost: 0, bitrate: 0 },
video: { bytesReceived: 0, frameWidth: 0, frameHeight: 0, framesPerSecond: 0, bitrate: 0 },
timestamp: Date.now()
video: {
bytesReceived: 0,
frameWidth: 0,
frameHeight: 0,
framesPerSecond: 0,
bitrate: 0,
},
timestamp: Date.now(),
};
stats.forEach(report => {
if (report.type === 'inbound-rtp') {
stats.forEach((report) => {
if (report.type === "inbound-rtp") {
const kind = report.kind;
if (kind === 'audio' || kind === 'video') {
const target = kind === 'audio' ? currentStats.audio : currentStats.video;
if (kind === "audio" || kind === "video") {
const target =
kind === "audio" ? currentStats.audio : currentStats.video;
target.bytesReceived = report.bytesReceived || 0;
if (kind === 'audio') {
if (kind === "audio") {
currentStats.audio.jitter = report.jitter || 0;
currentStats.audio.packetsLost = report.packetsLost || 0;
} else {
currentStats.video.frameWidth = report.frameWidth || 0;
currentStats.video.frameHeight = report.frameHeight || 0;
currentStats.video.framesPerSecond = report.framesPerSecond || 0;
currentStats.video.framesPerSecond =
report.framesPerSecond || 0;
}
if (prevStats) {
const prevTarget = kind === 'audio' ? prevStats.audio : prevStats.video;
const deltaBytes = target.bytesReceived - prevTarget.bytesReceived;
const deltaTime = (currentStats.timestamp - prevStats.timestamp) / 1000;
const prevTarget =
kind === "audio" ? prevStats.audio : prevStats.video;
const deltaBytes =
target.bytesReceived - prevTarget.bytesReceived;
const deltaTime =
(currentStats.timestamp - prevStats.timestamp) / 1000;
if (deltaTime > 0) {
target.bitrate = Math.max(0, deltaBytes * 8 / deltaTime);
target.bitrate = Math.max(0, (deltaBytes * 8) / deltaTime);
}
}
}
@@ -217,7 +300,10 @@ export const usePeerManager = (
});
newStats.set(peerIdHex, currentStats);
} catch (e) {
console.warn(`[WebRTC] Failed to get stats for ${peerIdHex}`, e);
console.warn(
`[WebRTC][${mediaType}] Failed to get stats for ${peerIdHex}`,
e,
);
}
}
peerStatsRef.current = newStats;
@@ -225,15 +311,18 @@ export const usePeerManager = (
}, 2000);
return () => clearInterval(interval);
}, [peers]);
}, [peers, mediaType]);
return {
return useMemo(
() => ({
peers,
peerStatuses,
peerStats,
createPeerConnection,
closePeer,
getPeer,
peersRef
};
peersRef,
}),
[peers, peerStatuses, peerStats, createPeerConnection, closePeer, getPeer],
);
};
@@ -0,0 +1,235 @@
import { useEffect, useCallback, useMemo, useRef } from "react";
import { Identity } from "spacetimedb";
import { useTable, useReducer } from "spacetimedb/react";
import { tables, reducers } from "../../../module_bindings";
import { usePeerManager } from "./usePeerManager";
export const useScreenSharingWebRTC = (
connectedChannelId: bigint | undefined,
identity: Identity | null,
localScreenStream: MediaStream | null
) => {
const [watching] = useTable(tables.watching);
const [offers] = useTable(tables.screen_sdp_offer);
const [answers] = useTable(tables.screen_sdp_answer);
const [iceCandidates] = useTable(tables.screen_ice_candidate);
const sendSdpOffer = useReducer(reducers.sendScreenSdpOffer);
const sendSdpAnswer = useReducer(reducers.sendScreenSdpAnswer);
const sendIceCandidate = useReducer(reducers.sendScreenIceCandidate);
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
const ignoreOfferRef = useRef<Map<string, boolean>>(new Map());
const processedOffersRef = useRef<Set<bigint>>(new Set());
const processedAnswersRef = useRef<Set<bigint>>(new Set());
const processedCandidatesRef = useRef<Set<bigint>>(new Set());
const candidateQueueRef = useRef<Map<string, any[]>>(new Map());
const connectedChannelIdRef = useRef(connectedChannelId);
useEffect(() => { connectedChannelIdRef.current = connectedChannelId; }, [connectedChannelId]);
const drainCandidateQueue = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
const queue = candidateQueueRef.current.get(peerIdHex) || [];
if (queue.length === 0 || !pc.remoteDescription) return;
console.log(`[WebRTC][screen] Draining ${queue.length} candidates for ${peerIdHex}`);
for (const cand of queue) {
try { await pc.addIceCandidate(new RTCIceCandidate(cand)); }
catch (e) { console.warn(`[WebRTC][screen] Error adding queued ICE for ${peerIdHex}`, e); }
}
candidateQueueRef.current.set(peerIdHex, []);
}, []);
const onNegotiationNeeded = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
const channelId = connectedChannelIdRef.current;
if (!channelId || pc.signalingState !== 'stable' || makingOfferRef.current.get(peerIdHex)) {
console.log(`[WebRTC][screen] Skipping negotiation for ${peerIdHex}: state=${pc.signalingState}, makingOffer=${makingOfferRef.current.get(peerIdHex)}`);
return;
}
try {
makingOfferRef.current.set(peerIdHex, true);
console.log(`[WebRTC][screen] Creating offer for ${peerIdHex}...`);
await pc.setLocalDescription();
sendSdpOffer({
receiver: Identity.fromString(peerIdHex),
sdp: JSON.stringify(pc.localDescription),
channelId
});
} catch (e) { console.error(`[WebRTC][screen] Negotiation error for ${peerIdHex}`, e); }
finally { makingOfferRef.current.set(peerIdHex, false); }
}, [sendSdpOffer]);
const onIceCandidate = useCallback((peerIdHex: string, candidate: RTCIceCandidate) => {
const channelId = connectedChannelIdRef.current;
if (channelId) {
sendIceCandidate({
receiver: Identity.fromString(peerIdHex),
candidate: JSON.stringify(candidate),
channelId
});
}
}, [sendIceCandidate]);
const peerManager = usePeerManager(
identity,
"screen",
false,
onNegotiationNeeded,
onIceCandidate
);
// Signaling
useEffect(() => {
if (!connectedChannelId || !identity) return;
const myOffers = offers.filter(o => o.receiver.isEqual(identity) && !o.sender.isEqual(identity) && o.channelId === connectedChannelId);
(async () => {
for (const offerRow of myOffers) {
if (processedOffersRef.current.has(offerRow.id)) continue;
processedOffersRef.current.add(offerRow.id);
const peerIdHex = offerRow.sender.toHexString();
console.log(`[WebRTC][screen] Received offer from ${peerIdHex}`);
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) continue;
try {
const isPolite = identity.toHexString() < peerIdHex;
const makingOffer = makingOfferRef.current.get(peerIdHex) || false;
const offerCollision = (pc.signalingState !== "stable") || makingOffer;
const ignoreOffer = !isPolite && offerCollision;
ignoreOfferRef.current.set(peerIdHex, ignoreOffer);
if (ignoreOffer) {
console.log(`[WebRTC][screen] Ignoring offer collision from ${peerIdHex} (impolite)`);
continue;
}
if (offerCollision) {
console.log(`[WebRTC][screen] Handling offer collision from ${peerIdHex} (polite), rolling back...`);
await pc.setLocalDescription({ type: "rollback" as RTCSdpType });
}
console.log(`[WebRTC][screen] Setting remote description from ${peerIdHex}`);
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(offerRow.sdp)));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
console.log(`[WebRTC][screen] Sending answer to ${peerIdHex}`);
sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId });
await drainCandidateQueue(peerIdHex, pc);
} catch (e) { console.error(`[WebRTC][screen] Error handling offer from ${peerIdHex}`, e); }
}
})();
const myAnswers = answers.filter(a => a.receiver.isEqual(identity) && !a.sender.isEqual(identity) && a.channelId === connectedChannelId);
(async () => {
for (const answerRow of myAnswers) {
if (processedAnswersRef.current.has(answerRow.id)) continue;
processedAnswersRef.current.add(answerRow.id);
const peerIdHex = answerRow.sender.toHexString();
const peer = peerManager.getPeer(peerIdHex);
if (peer) {
try {
console.log(`[WebRTC][screen] Received answer from ${peerIdHex}, setting remote description`);
await peer.pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(answerRow.sdp)));
await drainCandidateQueue(peerIdHex, peer.pc);
} catch (e) { console.error(`[WebRTC][screen] Error handling answer from ${peerIdHex}`, e); }
}
}
})();
const myCandidates = iceCandidates.filter(c => c.receiver.isEqual(identity) && !c.sender.isEqual(identity) && c.channelId === connectedChannelId);
(async () => {
for (const candRow of myCandidates) {
if (processedCandidatesRef.current.has(candRow.id)) continue;
processedCandidatesRef.current.add(candRow.id);
const peerIdHex = candRow.sender.toHexString();
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) continue;
try {
const candidate = JSON.parse(candRow.candidate);
const ignoreOffer = ignoreOfferRef.current.get(peerIdHex) || false;
if (pc.remoteDescription) {
console.log(`[WebRTC][screen] Adding ICE candidate from ${peerIdHex}`);
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else if (!ignoreOffer) {
console.log(`[WebRTC][screen] Queueing ICE candidate from ${peerIdHex}`);
const queue = candidateQueueRef.current.get(peerIdHex) || [];
queue.push(candidate);
candidateQueueRef.current.set(peerIdHex, queue);
}
} catch (e) { console.error(`[WebRTC][screen] Error handling ICE from ${peerIdHex}`, e); }
}
})();
}, [offers, answers, iceCandidates, connectedChannelId, identity, peerManager, sendSdpAnswer, drainCandidateQueue]);
// Track Syncing
useEffect(() => {
const videoTrack = localScreenStream?.getVideoTracks()[0] || null;
const audioTrack = localScreenStream?.getAudioTracks()[0] || null;
peerManager.peersRef.current.forEach(async (peer, peerIdHex) => {
const transceivers = peer.pc.getTransceivers();
let changed = false;
if (transceivers[0] && transceivers[0].sender.track !== videoTrack) {
await transceivers[0].sender.replaceTrack(videoTrack);
changed = true;
}
if (transceivers[1] && transceivers[1].sender.track !== audioTrack) {
await transceivers[1].sender.replaceTrack(audioTrack);
changed = true;
}
if (changed && peer.pc.signalingState === 'stable') {
console.log(`[WebRTC][screen] Syncing track for ${peerIdHex}`);
onNegotiationNeeded(peerIdHex, peer.pc);
}
});
}, [localScreenStream, peerManager.peers, onNegotiationNeeded, peerManager.peersRef]);
// Lifecycle
const screenPeersToConnect = useMemo(() => {
if (!identity || !connectedChannelId) return new Set<string>();
const peerIds = new Set<string>();
watching.forEach(w => {
if (w.channelId === connectedChannelId) {
if (w.watcher.isEqual(identity)) peerIds.add(w.watchee.toHexString());
else if (w.watchee.isEqual(identity)) peerIds.add(w.watcher.toHexString());
}
});
return peerIds;
}, [watching, identity, connectedChannelId]);
useEffect(() => {
if (!connectedChannelId || !identity) {
console.log(`[WebRTC][screen] Cleaning up connections (channel=${connectedChannelId}, identity=${!!identity})`);
peerManager.peersRef.current.forEach((_, id) => peerManager.closePeer(id));
processedOffersRef.current.clear();
processedAnswersRef.current.clear();
processedCandidatesRef.current.clear();
return;
}
screenPeersToConnect.forEach(id => {
if (!peerManager.peersRef.current.has(id)) {
console.log(`[WebRTC][screen] Connecting to watched peer ${id}`);
peerManager.createPeerConnection(id, [
localScreenStream?.getVideoTracks()[0] || null,
localScreenStream?.getAudioTracks()[0] || null
]);
}
});
peerManager.peersRef.current.forEach((_, id) => {
if (!screenPeersToConnect.has(id)) {
console.log(`[WebRTC][screen] Peer ${id} no longer watched, closing`);
peerManager.closePeer(id);
}
});
}, [screenPeersToConnect, connectedChannelId, identity, peerManager, localScreenStream]);
return {
peers: peerManager.peers
};
};
+113 -45
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useMemo } from "react";
import { Identity } from "spacetimedb";
import { useTable, useReducer } from "spacetimedb/react";
import { tables, reducers } from "../../../module_bindings";
@@ -6,10 +6,11 @@ import { tables, reducers } from "../../../module_bindings";
export const useSignaling = (
identity: Identity | null,
connectedChannelId: bigint | undefined,
mediaType: "voice" | "screen",
createPeerConnection: (peerIdHex: string) => RTCPeerConnection,
getPeer: (peerIdHex: string) => any,
makingOfferRef: React.MutableRefObject<Map<string, boolean>>,
ignoreOfferRef: React.MutableRefObject<Map<string, boolean>>
ignoreOfferRef: React.MutableRefObject<Map<string, boolean>>,
) => {
const [offers] = useTable(tables.sdp_offer);
const [answers] = useTable(tables.sdp_answer);
@@ -22,45 +23,54 @@ export const useSignaling = (
const processedCandidatesRef = useRef<Set<bigint>>(new Set());
const candidateQueueRef = useRef<Map<string, any[]>>(new Map());
const drainCandidateQueue = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
const drainCandidateQueue = useCallback(
async (peerIdHex: string, pc: RTCPeerConnection) => {
const queue = candidateQueueRef.current.get(peerIdHex) || [];
if (queue.length === 0) return;
// Safety: ensure we have a remote description before draining
if (!pc.remoteDescription) {
console.warn(`[WebRTC] Attempted to drain candidates for ${peerIdHex} but no remote description exists`);
console.warn(
`[WebRTC][${mediaType}] Attempted to drain candidates for ${peerIdHex} but no remote description exists`,
);
return;
}
console.log(`[WebRTC] Draining ${queue.length} queued candidates for ${peerIdHex}`);
console.log(
`[WebRTC][${mediaType}] Draining ${queue.length} queued candidates for ${peerIdHex}`,
);
for (const cand of queue) {
try {
await pc.addIceCandidate(new RTCIceCandidate(cand));
} catch (e) {
console.warn(`[WebRTC] Error adding queued ICE for ${peerIdHex}`, e);
console.warn(
`[WebRTC][${mediaType}] Error adding queued ICE for ${peerIdHex}`,
e,
);
}
}
candidateQueueRef.current.set(peerIdHex, []);
}, []);
},
[mediaType],
);
// Handle Offers
useEffect(() => {
if (!connectedChannelId || !identity) return;
const myOffers = offers.filter(o =>
const myOffers = offers.filter(
(o) =>
o.receiver.isEqual(identity) &&
!o.sender.isEqual(identity) &&
o.channelId === connectedChannelId
o.channelId === connectedChannelId &&
o.kind === mediaType,
);
const processOffers = async () => {
for (const offerRow of myOffers) {
if (processedOffersRef.current.has(offerRow.id)) continue;
// Mark as processed immediately to prevent duplicate processing during async gaps
processedOffersRef.current.add(offerRow.id);
const peerIdHex = offerRow.sender.toHexString();
console.log(`[WebRTC] Received offer from ${peerIdHex}`);
console.log(`[WebRTC][${mediaType}] Received offer from ${peerIdHex}`);
const pc = createPeerConnection(peerIdHex);
if (!pc) continue;
const offer = JSON.parse(offerRow.sdp);
@@ -68,43 +78,69 @@ export const useSignaling = (
try {
const isPolite = identity.toHexString() < peerIdHex;
const makingOffer = makingOfferRef.current.get(peerIdHex) || false;
const offerCollision = (pc.signalingState !== "stable") || makingOffer;
const offerCollision = pc.signalingState !== "stable" || makingOffer;
const ignoreOffer = !isPolite && offerCollision;
ignoreOfferRef.current.set(peerIdHex, ignoreOffer);
if (ignoreOffer) {
console.log(`[WebRTC] Ignoring offer collision from ${peerIdHex} (Impolite)`);
console.log(
`[WebRTC][${mediaType}] Ignoring offer collision from ${peerIdHex} (Impolite)`,
);
continue;
}
if (offerCollision) {
console.log(`[WebRTC] Handling offer collision from ${peerIdHex} (Polite), rolling back...`);
console.log(
`[WebRTC][${mediaType}] Handling offer collision from ${peerIdHex} (Polite), rolling back...`,
);
await pc.setLocalDescription({ type: "rollback" as RTCSdpType });
}
console.log(`[WebRTC] Setting remote description for ${peerIdHex}`);
console.log(
`[WebRTC][${mediaType}] Setting remote description for ${peerIdHex}`,
);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
console.log(`[WebRTC] Sending answer to ${peerIdHex}`);
sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId });
console.log(`[WebRTC][${mediaType}] Sending answer to ${peerIdHex}`);
sendSdpAnswer({
receiver: offerRow.sender,
sdp: JSON.stringify(answer),
channelId: connectedChannelId,
kind: mediaType,
});
await drainCandidateQueue(peerIdHex, pc);
} catch (e) {
console.error(`[WebRTC] Error handling offer from ${peerIdHex}`, e);
console.error(
`[WebRTC][${mediaType}] Error handling offer from ${peerIdHex}`,
e,
);
}
}
};
processOffers();
}, [offers, connectedChannelId, identity, createPeerConnection, sendSdpAnswer, drainCandidateQueue]);
}, [
offers,
connectedChannelId,
identity,
createPeerConnection,
sendSdpAnswer,
drainCandidateQueue,
mediaType,
makingOfferRef,
ignoreOfferRef,
]);
// Handle Answers
useEffect(() => {
if (!connectedChannelId || !identity) return;
const myAnswers = answers.filter(a =>
const myAnswers = answers.filter(
(a) =>
a.receiver.isEqual(identity) &&
!a.sender.isEqual(identity) &&
a.channelId === connectedChannelId
a.channelId === connectedChannelId &&
a.kind === mediaType,
);
const processAnswers = async () => {
@@ -116,26 +152,46 @@ export const useSignaling = (
const peer = getPeer(peerIdHex);
if (peer) {
try {
console.log(`[WebRTC] Received answer from ${peerIdHex}`);
console.log(
`[WebRTC][${mediaType}] Received answer from ${peerIdHex}`,
);
const answer = JSON.parse(answerRow.sdp);
await peer.pc.setRemoteDescription(new RTCSessionDescription(answer));
await peer.pc.setRemoteDescription(
new RTCSessionDescription(answer),
);
await drainCandidateQueue(peerIdHex, peer.pc);
} catch (e) { console.error(`[WebRTC] Error handling answer from ${peerIdHex}`, e); }
} catch (e) {
console.error(
`[WebRTC][${mediaType}] Error handling answer from ${peerIdHex}`,
e,
);
}
} else {
console.warn(`[WebRTC] Received answer from ${peerIdHex} but no PeerConnection exists`);
console.warn(
`[WebRTC][${mediaType}] Received answer from ${peerIdHex} but no PeerConnection exists`,
);
}
}
};
processAnswers();
}, [answers, connectedChannelId, identity, getPeer, drainCandidateQueue]);
}, [
answers,
connectedChannelId,
identity,
getPeer,
drainCandidateQueue,
mediaType,
]);
// Handle ICE Candidates
useEffect(() => {
if (!connectedChannelId || !identity) return;
const myCandidates = iceCandidates.filter(c =>
const myCandidates = iceCandidates.filter(
(c) =>
c.receiver.isEqual(identity) &&
!c.sender.isEqual(identity) &&
c.channelId === connectedChannelId
c.channelId === connectedChannelId &&
c.kind === mediaType,
);
const processCandidates = async () => {
@@ -144,7 +200,6 @@ export const useSignaling = (
processedCandidatesRef.current.add(candRow.id);
const peerIdHex = candRow.sender.toHexString();
// Ensure PeerConnection exists if we get a candidate
const pc = createPeerConnection(peerIdHex);
if (!pc) continue;
@@ -153,24 +208,35 @@ export const useSignaling = (
const candidate = JSON.parse(candRow.candidate);
if (pc.remoteDescription) {
console.log(`[WebRTC] Adding ICE candidate from ${peerIdHex}`);
console.log(
`[WebRTC][${mediaType}] Adding ICE candidate from ${peerIdHex}`,
);
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else if (!ignoreOffer) {
console.log(`[WebRTC] Queueing ICE candidate from ${peerIdHex}`);
console.log(
`[WebRTC][${mediaType}] Queueing ICE candidate from ${peerIdHex}`,
);
const queue = candidateQueueRef.current.get(peerIdHex) || [];
queue.push(candidate);
candidateQueueRef.current.set(peerIdHex, queue);
} else {
console.log(`[WebRTC] Ignoring ICE candidate from ${peerIdHex} (ignoreOffer=true)`);
}
} catch (e) { console.error(`[WebRTC] Error handling ICE from ${peerIdHex}`, e); }
} catch (e) {
console.error(
`[WebRTC][${mediaType}] Error handling ICE from ${peerIdHex}`,
e,
);
}
}
};
processCandidates();
}, [iceCandidates, connectedChannelId, identity, createPeerConnection]);
}, [
iceCandidates,
connectedChannelId,
identity,
createPeerConnection,
mediaType,
ignoreOfferRef,
]);
const clearSignalingState = useCallback(() => {
processedOffersRef.current.clear();
@@ -179,10 +245,12 @@ export const useSignaling = (
makingOfferRef.current.clear();
ignoreOfferRef.current.clear();
candidateQueueRef.current.clear();
}, []);
}, [makingOfferRef, ignoreOfferRef]);
return {
makingOfferRef,
clearSignalingState
};
return useMemo(
() => ({
clearSignalingState,
}),
[clearSignalingState],
);
};
+19 -182
View File
@@ -1,30 +1,18 @@
import { useEffect, useCallback, useMemo, useRef } from "react";
import { useCallback } from "react";
import { Identity } from "spacetimedb";
import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react";
import { tables, reducers } from "../../../module_bindings";
import { useLocalMedia } from "./useLocalMedia";
import { usePeerManager } from "./usePeerManager";
import { useSignaling } from "./useSignaling";
import { useChannelAudioWebRTC } from "./useChannelAudioWebRTC";
import { useScreenSharingWebRTC } from "./useScreenSharingWebRTC";
export const useWebRTC = (connectedChannelId: bigint | undefined) => {
const { identity } = useSpacetimeDB();
const [voiceStates] = useTable(tables.voice_state);
const [watching] = useTable(tables.watching);
const sendSdpOffer = useReducer(reducers.sendSdpOffer);
const sendIceCandidate = useReducer(reducers.sendIceCandidate);
const startWatchingReducer = useReducer(reducers.startWatching);
const stopWatchingReducer = useReducer(reducers.stopWatching);
// Refs for signaling state to avoid circular dependencies and stale closures
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
const ignoreOfferRef = useRef<Map<string, boolean>>(new Map());
const connectedChannelIdRef = useRef<bigint | undefined>(connectedChannelId);
useEffect(() => {
connectedChannelIdRef.current = connectedChannelId;
}, [connectedChannelId]);
const {
localStream,
localScreenStream,
@@ -38,179 +26,29 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
stopScreenShare: stopLocalScreenShare,
requestMic,
releaseMic,
localStreamRef,
localScreenStreamRef
} = useLocalMedia();
const onNegotiationNeeded = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
// Always check the LATEST channel ID from ref
const channelId = connectedChannelIdRef.current;
const isMakingOffer = makingOfferRef.current.get(peerIdHex);
if (!channelId || pc.signalingState !== 'stable' || isMakingOffer) {
console.log(`[WebRTC] Skipping negotiation for ${peerIdHex}: channel=${!!channelId}, state=${pc.signalingState}, makingOffer=${isMakingOffer}`);
return;
}
try {
makingOfferRef.current.set(peerIdHex, true);
console.log(`[WebRTC] Negotiation needed for ${peerIdHex}, creating offer...`);
await pc.setLocalDescription();
console.log(`[WebRTC] Sending offer to ${peerIdHex}`);
sendSdpOffer({
receiver: Identity.fromString(peerIdHex),
sdp: JSON.stringify(pc.localDescription),
channelId
});
} catch (e) {
console.error(`[WebRTC] Error during negotiation for ${peerIdHex}`, e);
} finally {
makingOfferRef.current.set(peerIdHex, false);
}
}, [sendSdpOffer]);
const onIceCandidate = useCallback((peerIdHex: string, candidate: RTCIceCandidate) => {
const channelId = connectedChannelIdRef.current;
if (channelId) {
sendIceCandidate({
receiver: Identity.fromString(peerIdHex),
candidate: JSON.stringify(candidate),
channelId
});
}
}, [sendIceCandidate]);
const {
peers,
peerStatuses,
peerStats,
createPeerConnection,
closePeer,
getPeer,
peersRef
} = usePeerManager(
identity,
isDeafened,
localStreamRef,
localScreenStreamRef,
onNegotiationNeeded,
onIceCandidate
);
const {
clearSignalingState
} = useSignaling(
identity,
// --- Specialized Hooks ---
const voice = useChannelAudioWebRTC(
connectedChannelId,
createPeerConnection,
getPeer,
makingOfferRef,
ignoreOfferRef
identity,
localStream,
isDeafened
);
// Sync local media to existing peers
useEffect(() => {
const audioTrack = localStream?.getAudioTracks()[0] || null;
peersRef.current.forEach(async (peer, peerIdHex) => {
const transceivers = peer.pc.getTransceivers();
if (transceivers[0] && transceivers[0].sender.track !== audioTrack) {
console.log(`[WebRTC] Syncing audio track to peer ${peerIdHex}`);
try {
await transceivers[0].sender.replaceTrack(audioTrack);
if (peer.pc.signalingState === 'stable') {
onNegotiationNeeded(peerIdHex, peer.pc);
}
} catch (e) {
console.error(`[WebRTC] Error replacing audio track for ${peerIdHex}`, e);
}
}
});
}, [localStream, peers, onNegotiationNeeded]);
const screen = useScreenSharingWebRTC(
connectedChannelId,
identity,
localScreenStream
);
useEffect(() => {
const videoTrack = localScreenStream?.getVideoTracks()[0] || null;
peersRef.current.forEach(async (peer, peerIdHex) => {
const transceivers = peer.pc.getTransceivers();
if (transceivers[1] && transceivers[1].sender.track !== videoTrack) {
console.log(`[WebRTC] Syncing video track to peer ${peerIdHex}`);
try {
await transceivers[1].sender.replaceTrack(videoTrack);
if (peer.pc.signalingState === 'stable') {
onNegotiationNeeded(peerIdHex, peer.pc);
}
} catch (e) {
console.error(`[WebRTC] Error replacing video track for ${peerIdHex}`, e);
}
}
});
}, [localScreenStream, peers, onNegotiationNeeded]);
// Determine who to connect to
const peersToConnect = useMemo(() => {
if (!identity || !connectedChannelId) return new Set<string>();
const peerIds = new Set<string>();
voiceStates.forEach(vs => {
if (vs.channelId === connectedChannelId && !vs.identity.isEqual(identity)) {
peerIds.add(vs.identity.toHexString());
}
});
watching.forEach(w => {
if (w.watcher.isEqual(identity)) {
peerIds.add(w.watchee.toHexString());
} else if (w.watchee.isEqual(identity)) {
peerIds.add(w.watcher.toHexString());
}
});
return peerIds;
}, [voiceStates, watching, identity, connectedChannelId]);
// Peer Lifecycle Orchestration
useEffect(() => {
if (!connectedChannelId || !identity) {
// Cleanup all
if (peersRef.current.size > 0) {
console.log("[WebRTC] Cleaning up all peer connections");
peersRef.current.forEach((_, peerIdHex) => closePeer(peerIdHex));
}
releaseMic();
clearSignalingState();
return;
}
// Always clear signaling state when connectedChannelId changes to avoid stale row processing
clearSignalingState();
// Connect to new peers
peersToConnect.forEach(peerIdHex => {
if (!peersRef.current.has(peerIdHex)) {
createPeerConnection(peerIdHex);
}
});
// Cleanup disconnected peers
peersRef.current.forEach((_, peerIdHex) => {
if (!peersToConnect.has(peerIdHex)) {
closePeer(peerIdHex);
}
});
requestMic();
}, [peersToConnect, connectedChannelId, identity, createPeerConnection, closePeer, requestMic, releaseMic, clearSignalingState]);
// Screen Share Actions
// --- Actions ---
const startScreenShare = useCallback(() => {
startLocalScreenShare((track) => {
// Handled by localScreenStream effect
});
startLocalScreenShare(() => {});
}, [startLocalScreenShare]);
const stopScreenShare = useCallback(() => {
stopLocalScreenShare((track) => {
// Handled by localScreenStream effect
});
stopLocalScreenShare(() => {});
}, [stopLocalScreenShare]);
const startWatching = useCallback((peerIdentity: Identity) => {
@@ -226,8 +64,8 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
return {
localStream,
localScreenStream,
peerStatuses,
peers,
peerStatuses: voice.peerStatuses,
peers: screen.peers, // For VideoGrid to show streams
startScreenShare,
stopScreenShare,
isSharingScreen,
@@ -238,9 +76,8 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
isDeafened,
toggleMute,
toggleDeafen,
peerStats
peerStats: voice.peerStats
};
};
export default useWebRTC;
+3 -3
View File
@@ -33,15 +33,15 @@ body,
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}
/* ----- Buttons ----- */
+31 -23
View File
@@ -1,10 +1,10 @@
import { StrictMode, useMemo } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { Identity } from 'spacetimedb';
import { SpacetimeDBProvider } from 'spacetimedb/react';
import { DbConnection, ErrorContext } from './module_bindings/index.ts';
import { StrictMode, useMemo } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { Identity } from "spacetimedb";
import { SpacetimeDBProvider } from "spacetimedb/react";
import { DbConnection, ErrorContext } from "./module_bindings/index.ts";
import { OidcProvider } from "./auth"; // Import from index.ts
import { AuthGate } from "./auth"; // Import from index.ts
@@ -12,24 +12,26 @@ import { AuthGate } from "./auth"; // Import from index.ts
// which it does. SpacetimeDBWrapper relies on useAuth to get the OIDC token.
import { useAuth } from "react-oidc-context";
const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'wss://maincloud.spacetimedb.com';
const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'my-spacetime-app-jdhdg';
const HOST =
import.meta.env.VITE_SPACETIMEDB_HOST ?? "wss://maincloud.spacetimedb.com";
const DB_NAME =
import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? "my-spacetime-app-jdhdg";
export const TOKEN_KEY = `${HOST}/${DB_NAME}/auth_token`;
const onConnect = (conn: DbConnection, identity: Identity, token: string) => {
localStorage.setItem(TOKEN_KEY, token);
console.log(
'Connected to SpacetimeDB with identity:',
identity.toHexString()
"Connected to SpacetimeDB with identity:",
identity.toHexString(),
);
};
const onDisconnect = () => {
console.log('Disconnected from SpacetimeDB');
console.log("Disconnected from SpacetimeDB");
};
const onConnectError = (_ctx: ErrorContext, err: Error) => {
console.log('Error connecting to SpacetimeDB:', err);
console.log("Error connecting to SpacetimeDB:", err);
};
// This component remains responsible for the SpacetimeDB connection logic.
@@ -38,11 +40,14 @@ function SpacetimeDBWrapper({ children }: { children: React.ReactNode }) {
const auth = useAuth();
// Logging authentication and token status
console.log('SpacetimeDBWrapper: auth.isLoading:', auth.isLoading);
console.log('SpacetimeDBWrapper: auth.isAuthenticated:', auth.isAuthenticated);
console.log('SpacetimeDBWrapper: auth.user?.id_token:', auth.user?.id_token);
console.log("SpacetimeDBWrapper: auth.isLoading:", auth.isLoading);
console.log(
"SpacetimeDBWrapper: auth.isAuthenticated:",
auth.isAuthenticated,
);
console.log("SpacetimeDBWrapper: auth.user?.id_token:", auth.user?.id_token);
const storedToken = localStorage.getItem(TOKEN_KEY);
console.log('SpacetimeDBWrapper: localStorage TOKEN_KEY:', storedToken);
console.log("SpacetimeDBWrapper: localStorage TOKEN_KEY:", storedToken);
const connectionBuilder = useMemo(() => {
const builder = DbConnection.builder()
@@ -58,16 +63,19 @@ function SpacetimeDBWrapper({ children }: { children: React.ReactNode }) {
console.log("SpacetimeDBWrapper: Connecting with OIDC token");
return builder.withToken(auth.user.id_token);
} else if (storedToken) {
console.log("SpacetimeDBWrapper: Connecting with stored SpacetimeDB token");
console.log(
"SpacetimeDBWrapper: Connecting with stored SpacetimeDB token",
);
return builder.withToken(storedToken);
} else {
console.log("SpacetimeDBWrapper: No token available, proceeding without.");
console.log(
"SpacetimeDBWrapper: No token available, proceeding without.",
);
return builder; // Proceed without a token if none is available
}
}, [auth.isAuthenticated, auth.user?.id_token, storedToken]); // Include storedToken in dependencies
console.log('SpacetimeDBWrapper: connectionBuilder created.');
console.log("SpacetimeDBWrapper: connectionBuilder created.");
return (
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
@@ -76,7 +84,7 @@ function SpacetimeDBWrapper({ children }: { children: React.ReactNode }) {
);
}
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<OidcProvider>
<SpacetimeDBWrapper>
@@ -86,5 +94,5 @@ createRoot(document.getElementById('root')!).render(
</AuthGate>
</SpacetimeDBWrapper>
</OidcProvider>
</StrictMode>
</StrictMode>,
);
+1 -4
View File
@@ -9,10 +9,7 @@ import {
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
import {
ChannelKind,
} from "./types";
import { ChannelKind } from "./types";
export default __t.row({
id: __t.u64().primaryKey(),
+401 -144
View File
@@ -43,10 +43,13 @@ import LeaveServerReducer from "./leave_server_reducer";
import LeaveVoiceReducer from "./leave_voice_reducer";
import LoginReducer from "./login_reducer";
import RegisterReducer from "./register_reducer";
import SendIceCandidateReducer from "./send_ice_candidate_reducer";
import SendMessageReducer from "./send_message_reducer";
import SendSdpAnswerReducer from "./send_sdp_answer_reducer";
import SendSdpOfferReducer from "./send_sdp_offer_reducer";
import SendScreenIceCandidateReducer from "./send_screen_ice_candidate_reducer";
import SendScreenSdpAnswerReducer from "./send_screen_sdp_answer_reducer";
import SendScreenSdpOfferReducer from "./send_screen_sdp_offer_reducer";
import SendVoiceIceCandidateReducer from "./send_voice_ice_candidate_reducer";
import SendVoiceSdpAnswerReducer from "./send_voice_sdp_answer_reducer";
import SendVoiceSdpOfferReducer from "./send_voice_sdp_offer_reducer";
import SetNameReducer from "./set_name_reducer";
import SetSharingScreenReducer from "./set_sharing_screen_reducer";
import SetTalkingReducer from "./set_talking_reducer";
@@ -57,14 +60,17 @@ import StopWatchingReducer from "./stop_watching_reducer";
// Import all table schema definitions
import ChannelRow from "./channel_table";
import IceCandidateRow from "./ice_candidate_table";
import MessageRow from "./message_table";
import SdpAnswerRow from "./sdp_answer_table";
import SdpOfferRow from "./sdp_offer_table";
import ScreenIceCandidateRow from "./screen_ice_candidate_table";
import ScreenSdpAnswerRow from "./screen_sdp_answer_table";
import ScreenSdpOfferRow from "./screen_sdp_offer_table";
import ServerRow from "./server_table";
import ServerMemberRow from "./server_member_table";
import ThreadRow from "./thread_table";
import UserRow from "./user_table";
import VoiceIceCandidateRow from "./voice_ice_candidate_table";
import VoiceSdpAnswerRow from "./voice_sdp_answer_table";
import VoiceSdpOfferRow from "./voice_sdp_offer_table";
import VoiceStateRow from "./voice_state_table";
import WatchingRow from "./watching_table";
@@ -72,176 +78,413 @@ import WatchingRow from "./watching_table";
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
const tablesSchema = __schema({
channel: __table({
name: 'channel',
channel: __table(
{
name: "channel",
indexes: [
{ accessor: 'id', name: 'channel_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'by_server_id', name: 'channel_server_id_idx_btree', algorithm: 'btree', columns: [
'serverId',
] },
{
accessor: "id",
name: "channel_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_server_id",
name: "channel_server_id_idx_btree",
algorithm: "btree",
columns: ["serverId"],
},
],
constraints: [
{ name: 'channel_id_key', constraint: 'unique', columns: ['id'] },
{ name: "channel_id_key", constraint: "unique", columns: ["id"] },
],
}, ChannelRow),
ice_candidate: __table({
name: 'ice_candidate',
},
ChannelRow,
),
message: __table(
{
name: "message",
indexes: [
{ accessor: 'id', name: 'ice_candidate_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'by_receiver', name: 'ice_candidate_receiver_idx_btree', algorithm: 'btree', columns: [
'receiver',
] },
{ accessor: 'by_sender', name: 'ice_candidate_sender_idx_btree', algorithm: 'btree', columns: [
'sender',
] },
{
accessor: "by_channel_id",
name: "message_channel_id_idx_btree",
algorithm: "btree",
columns: ["channelId"],
},
{
accessor: "id",
name: "message_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_thread_id",
name: "message_thread_id_idx_btree",
algorithm: "btree",
columns: ["threadId"],
},
],
constraints: [
{ name: 'ice_candidate_id_key', constraint: 'unique', columns: ['id'] },
{ name: "message_id_key", constraint: "unique", columns: ["id"] },
],
}, IceCandidateRow),
message: __table({
name: 'message',
},
MessageRow,
),
screen_ice_candidate: __table(
{
name: "screen_ice_candidate",
indexes: [
{ accessor: 'by_channel_id', name: 'message_channel_id_idx_btree', algorithm: 'btree', columns: [
'channelId',
] },
{ accessor: 'id', name: 'message_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'by_thread_id', name: 'message_thread_id_idx_btree', algorithm: 'btree', columns: [
'threadId',
] },
{
accessor: "id",
name: "screen_ice_candidate_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_receiver",
name: "screen_ice_candidate_receiver_idx_btree",
algorithm: "btree",
columns: ["receiver"],
},
{
accessor: "by_sender",
name: "screen_ice_candidate_sender_idx_btree",
algorithm: "btree",
columns: ["sender"],
},
],
constraints: [
{ name: 'message_id_key', constraint: 'unique', columns: ['id'] },
{
name: "screen_ice_candidate_id_key",
constraint: "unique",
columns: ["id"],
},
],
}, MessageRow),
sdp_answer: __table({
name: 'sdp_answer',
},
ScreenIceCandidateRow,
),
screen_sdp_answer: __table(
{
name: "screen_sdp_answer",
indexes: [
{ accessor: 'id', name: 'sdp_answer_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'by_receiver', name: 'sdp_answer_receiver_idx_btree', algorithm: 'btree', columns: [
'receiver',
] },
{ accessor: 'by_sender', name: 'sdp_answer_sender_idx_btree', algorithm: 'btree', columns: [
'sender',
] },
{
accessor: "id",
name: "screen_sdp_answer_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_receiver",
name: "screen_sdp_answer_receiver_idx_btree",
algorithm: "btree",
columns: ["receiver"],
},
{
accessor: "by_sender",
name: "screen_sdp_answer_sender_idx_btree",
algorithm: "btree",
columns: ["sender"],
},
],
constraints: [
{ name: 'sdp_answer_id_key', constraint: 'unique', columns: ['id'] },
{
name: "screen_sdp_answer_id_key",
constraint: "unique",
columns: ["id"],
},
],
}, SdpAnswerRow),
sdp_offer: __table({
name: 'sdp_offer',
},
ScreenSdpAnswerRow,
),
screen_sdp_offer: __table(
{
name: "screen_sdp_offer",
indexes: [
{ accessor: 'id', name: 'sdp_offer_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'by_receiver', name: 'sdp_offer_receiver_idx_btree', algorithm: 'btree', columns: [
'receiver',
] },
{ accessor: 'by_sender', name: 'sdp_offer_sender_idx_btree', algorithm: 'btree', columns: [
'sender',
] },
{
accessor: "id",
name: "screen_sdp_offer_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_receiver",
name: "screen_sdp_offer_receiver_idx_btree",
algorithm: "btree",
columns: ["receiver"],
},
{
accessor: "by_sender",
name: "screen_sdp_offer_sender_idx_btree",
algorithm: "btree",
columns: ["sender"],
},
],
constraints: [
{ name: 'sdp_offer_id_key', constraint: 'unique', columns: ['id'] },
{
name: "screen_sdp_offer_id_key",
constraint: "unique",
columns: ["id"],
},
],
}, SdpOfferRow),
server: __table({
name: 'server',
},
ScreenSdpOfferRow,
),
server: __table(
{
name: "server",
indexes: [
{ accessor: 'id', name: 'server_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{
accessor: "id",
name: "server_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
],
constraints: [
{ name: 'server_id_key', constraint: 'unique', columns: ['id'] },
{ name: "server_id_key", constraint: "unique", columns: ["id"] },
],
}, ServerRow),
server_member: __table({
name: 'server_member',
},
ServerRow,
),
server_member: __table(
{
name: "server_member",
indexes: [
{ accessor: 'id', name: 'server_member_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'by_identity', name: 'server_member_identity_idx_btree', algorithm: 'btree', columns: [
'identity',
] },
{ accessor: 'by_server_id', name: 'server_member_server_id_idx_btree', algorithm: 'btree', columns: [
'serverId',
] },
{
accessor: "id",
name: "server_member_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_identity",
name: "server_member_identity_idx_btree",
algorithm: "btree",
columns: ["identity"],
},
{
accessor: "by_server_id",
name: "server_member_server_id_idx_btree",
algorithm: "btree",
columns: ["serverId"],
},
],
constraints: [
{ name: 'server_member_id_key', constraint: 'unique', columns: ['id'] },
{ name: "server_member_id_key", constraint: "unique", columns: ["id"] },
],
}, ServerMemberRow),
thread: __table({
name: 'thread',
},
ServerMemberRow,
),
thread: __table(
{
name: "thread",
indexes: [
{ accessor: 'by_channel_id', name: 'thread_channel_id_idx_btree', algorithm: 'btree', columns: [
'channelId',
] },
{ accessor: 'id', name: 'thread_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'parent_message_id', name: 'thread_parent_message_id_idx_btree', algorithm: 'btree', columns: [
'parentMessageId',
] },
{
accessor: "by_channel_id",
name: "thread_channel_id_idx_btree",
algorithm: "btree",
columns: ["channelId"],
},
{
accessor: "id",
name: "thread_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "parent_message_id",
name: "thread_parent_message_id_idx_btree",
algorithm: "btree",
columns: ["parentMessageId"],
},
],
constraints: [
{ name: 'thread_id_key', constraint: 'unique', columns: ['id'] },
{ name: 'thread_parent_message_id_key', constraint: 'unique', columns: ['parentMessageId'] },
{ name: "thread_id_key", constraint: "unique", columns: ["id"] },
{
name: "thread_parent_message_id_key",
constraint: "unique",
columns: ["parentMessageId"],
},
],
}, ThreadRow),
user: __table({
name: 'user',
},
ThreadRow,
),
user: __table(
{
name: "user",
indexes: [
{ accessor: 'identity', name: 'user_identity_idx_btree', algorithm: 'btree', columns: [
'identity',
] },
{
accessor: "identity",
name: "user_identity_idx_btree",
algorithm: "btree",
columns: ["identity"],
},
],
constraints: [
{ name: 'user_identity_key', constraint: 'unique', columns: ['identity'] },
{
name: "user_identity_key",
constraint: "unique",
columns: ["identity"],
},
],
}, UserRow),
voice_state: __table({
name: 'voice_state',
},
UserRow,
),
voice_ice_candidate: __table(
{
name: "voice_ice_candidate",
indexes: [
{ accessor: 'by_channel_id', name: 'voice_state_channel_id_idx_btree', algorithm: 'btree', columns: [
'channelId',
] },
{ accessor: 'identity', name: 'voice_state_identity_idx_btree', algorithm: 'btree', columns: [
'identity',
] },
{
accessor: "id",
name: "voice_ice_candidate_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_receiver",
name: "voice_ice_candidate_receiver_idx_btree",
algorithm: "btree",
columns: ["receiver"],
},
{
accessor: "by_sender",
name: "voice_ice_candidate_sender_idx_btree",
algorithm: "btree",
columns: ["sender"],
},
],
constraints: [
{ name: 'voice_state_identity_key', constraint: 'unique', columns: ['identity'] },
{
name: "voice_ice_candidate_id_key",
constraint: "unique",
columns: ["id"],
},
],
}, VoiceStateRow),
watching: __table({
name: 'watching',
},
VoiceIceCandidateRow,
),
voice_sdp_answer: __table(
{
name: "voice_sdp_answer",
indexes: [
{ accessor: 'id', name: 'watching_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
{ accessor: 'by_watchee', name: 'watching_watchee_idx_btree', algorithm: 'btree', columns: [
'watchee',
] },
{ accessor: 'by_watcher', name: 'watching_watcher_idx_btree', algorithm: 'btree', columns: [
'watcher',
] },
{
accessor: "id",
name: "voice_sdp_answer_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_receiver",
name: "voice_sdp_answer_receiver_idx_btree",
algorithm: "btree",
columns: ["receiver"],
},
{
accessor: "by_sender",
name: "voice_sdp_answer_sender_idx_btree",
algorithm: "btree",
columns: ["sender"],
},
],
constraints: [
{ name: 'watching_id_key', constraint: 'unique', columns: ['id'] },
{
name: "voice_sdp_answer_id_key",
constraint: "unique",
columns: ["id"],
},
],
}, WatchingRow),
},
VoiceSdpAnswerRow,
),
voice_sdp_offer: __table(
{
name: "voice_sdp_offer",
indexes: [
{
accessor: "id",
name: "voice_sdp_offer_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_receiver",
name: "voice_sdp_offer_receiver_idx_btree",
algorithm: "btree",
columns: ["receiver"],
},
{
accessor: "by_sender",
name: "voice_sdp_offer_sender_idx_btree",
algorithm: "btree",
columns: ["sender"],
},
],
constraints: [
{
name: "voice_sdp_offer_id_key",
constraint: "unique",
columns: ["id"],
},
],
},
VoiceSdpOfferRow,
),
voice_state: __table(
{
name: "voice_state",
indexes: [
{
accessor: "by_channel_id",
name: "voice_state_channel_id_idx_btree",
algorithm: "btree",
columns: ["channelId"],
},
{
accessor: "identity",
name: "voice_state_identity_idx_btree",
algorithm: "btree",
columns: ["identity"],
},
],
constraints: [
{
name: "voice_state_identity_key",
constraint: "unique",
columns: ["identity"],
},
],
},
VoiceStateRow,
),
watching: __table(
{
name: "watching",
indexes: [
{
accessor: "id",
name: "watching_id_idx_btree",
algorithm: "btree",
columns: ["id"],
},
{
accessor: "by_watchee",
name: "watching_watchee_idx_btree",
algorithm: "btree",
columns: ["watchee"],
},
{
accessor: "by_watcher",
name: "watching_watcher_idx_btree",
algorithm: "btree",
columns: ["watcher"],
},
],
constraints: [
{ name: "watching_id_key", constraint: "unique", columns: ["id"] },
],
},
WatchingRow,
),
});
/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */
@@ -255,10 +498,13 @@ const reducersSchema = __reducers(
__reducerSchema("leave_voice", LeaveVoiceReducer),
__reducerSchema("login", LoginReducer),
__reducerSchema("register", RegisterReducer),
__reducerSchema("send_ice_candidate", SendIceCandidateReducer),
__reducerSchema("send_message", SendMessageReducer),
__reducerSchema("send_sdp_answer", SendSdpAnswerReducer),
__reducerSchema("send_sdp_offer", SendSdpOfferReducer),
__reducerSchema("send_screen_ice_candidate", SendScreenIceCandidateReducer),
__reducerSchema("send_screen_sdp_answer", SendScreenSdpAnswerReducer),
__reducerSchema("send_screen_sdp_offer", SendScreenSdpOfferReducer),
__reducerSchema("send_voice_ice_candidate", SendVoiceIceCandidateReducer),
__reducerSchema("send_voice_sdp_answer", SendVoiceSdpAnswerReducer),
__reducerSchema("send_voice_sdp_offer", SendVoiceSdpOfferReducer),
__reducerSchema("set_name", SetNameReducer),
__reducerSchema("set_sharing_screen", SetSharingScreenReducer),
__reducerSchema("set_talking", SetTalkingReducer),
@@ -267,8 +513,7 @@ const reducersSchema = __reducers(
);
/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */
const proceduresSchema = __procedures(
);
const proceduresSchema = __procedures();
/** The remote SpacetimeDB module schema, both runtime and type information. */
const REMOTE_MODULE = {
@@ -285,24 +530,33 @@ const REMOTE_MODULE = {
>;
/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */
export const tables: __QueryBuilder<typeof tablesSchema.schemaType> = __makeQueryBuilder(tablesSchema.schemaType);
export const tables: __QueryBuilder<typeof tablesSchema.schemaType> =
__makeQueryBuilder(tablesSchema.schemaType);
/** The reducers available in this remote SpacetimeDB module. */
export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);
export const reducers = __convertToAccessorMap(
reducersSchema.reducersType.reducers,
);
/** The context type returned in callbacks for all possible events. */
export type EventContext = __EventContextInterface<typeof REMOTE_MODULE>;
/** The context type returned in callbacks for reducer events. */
export type ReducerEventContext = __ReducerEventContextInterface<typeof REMOTE_MODULE>;
export type ReducerEventContext = __ReducerEventContextInterface<
typeof REMOTE_MODULE
>;
/** The context type returned in callbacks for subscription events. */
export type SubscriptionEventContext = __SubscriptionEventContextInterface<typeof REMOTE_MODULE>;
export type SubscriptionEventContext = __SubscriptionEventContextInterface<
typeof REMOTE_MODULE
>;
/** The context type returned in callbacks for error events. */
export type ErrorContext = __ErrorContextInterface<typeof REMOTE_MODULE>;
/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */
export type SubscriptionHandle = __SubscriptionHandleImpl<typeof REMOTE_MODULE>;
/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */
export class SubscriptionBuilder extends __SubscriptionBuilderImpl<typeof REMOTE_MODULE> {}
export class SubscriptionBuilder extends __SubscriptionBuilderImpl<
typeof REMOTE_MODULE
> {}
/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */
export class DbConnectionBuilder extends __DbConnectionBuilder<DbConnection> {}
@@ -311,7 +565,11 @@ export class DbConnectionBuilder extends __DbConnectionBuilder<DbConnection> {}
export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
/** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */
static builder = (): DbConnectionBuilder => {
return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig<typeof REMOTE_MODULE>) => new DbConnection(config));
return new DbConnectionBuilder(
REMOTE_MODULE,
(config: __DbConnectionConfig<typeof REMOTE_MODULE>) =>
new DbConnection(config),
);
};
/** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */
@@ -319,4 +577,3 @@ export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
return new SubscriptionBuilder(this);
};
}
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
receiver: __t.identity(),
candidate: __t.string(),
channelId: __t.u64(),
};
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
};
+17
View File
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
};
+41 -15
View File
@@ -27,15 +27,6 @@ export const ChannelKind = __t.enum("ChannelKind", {
});
export type ChannelKind = __Infer<typeof ChannelKind>;
export const IceCandidate = __t.object("IceCandidate", {
id: __t.u64(),
sender: __t.identity(),
receiver: __t.identity(),
candidate: __t.string(),
channelId: __t.u64(),
});
export type IceCandidate = __Infer<typeof IceCandidate>;
export const Message = __t.object("Message", {
id: __t.u64(),
sender: __t.identity(),
@@ -46,23 +37,32 @@ export const Message = __t.object("Message", {
});
export type Message = __Infer<typeof Message>;
export const SdpAnswer = __t.object("SdpAnswer", {
export const ScreenIceCandidate = __t.object("ScreenIceCandidate", {
id: __t.u64(),
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
candidate: __t.string(),
channelId: __t.u64(),
});
export type SdpAnswer = __Infer<typeof SdpAnswer>;
export type ScreenIceCandidate = __Infer<typeof ScreenIceCandidate>;
export const SdpOffer = __t.object("SdpOffer", {
export const ScreenSdpAnswer = __t.object("ScreenSdpAnswer", {
id: __t.u64(),
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
});
export type SdpOffer = __Infer<typeof SdpOffer>;
export type ScreenSdpAnswer = __Infer<typeof ScreenSdpAnswer>;
export const ScreenSdpOffer = __t.object("ScreenSdpOffer", {
id: __t.u64(),
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
});
export type ScreenSdpOffer = __Infer<typeof ScreenSdpOffer>;
export const Server = __t.object("Server", {
id: __t.u64(),
@@ -98,6 +98,33 @@ export const User = __t.object("User", {
});
export type User = __Infer<typeof User>;
export const VoiceIceCandidate = __t.object("VoiceIceCandidate", {
id: __t.u64(),
sender: __t.identity(),
receiver: __t.identity(),
candidate: __t.string(),
channelId: __t.u64(),
});
export type VoiceIceCandidate = __Infer<typeof VoiceIceCandidate>;
export const VoiceSdpAnswer = __t.object("VoiceSdpAnswer", {
id: __t.u64(),
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
});
export type VoiceSdpAnswer = __Infer<typeof VoiceSdpAnswer>;
export const VoiceSdpOffer = __t.object("VoiceSdpOffer", {
id: __t.u64(),
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64(),
});
export type VoiceSdpOffer = __Infer<typeof VoiceSdpOffer>;
export const VoiceState = __t.object("VoiceState", {
identity: __t.identity(),
channelId: __t.u64(),
@@ -112,4 +139,3 @@ export const Watching = __t.object("Watching", {
channelId: __t.u64(),
});
export type Watching = __Infer<typeof Watching>;
-2
View File
@@ -6,5 +6,3 @@
import { type Infer as __Infer } from "spacetimedb";
// Import all procedure arg schemas
+22 -7
View File
@@ -15,10 +15,13 @@ import LeaveServerReducer from "../leave_server_reducer";
import LeaveVoiceReducer from "../leave_voice_reducer";
import LoginReducer from "../login_reducer";
import RegisterReducer from "../register_reducer";
import SendIceCandidateReducer from "../send_ice_candidate_reducer";
import SendMessageReducer from "../send_message_reducer";
import SendSdpAnswerReducer from "../send_sdp_answer_reducer";
import SendSdpOfferReducer from "../send_sdp_offer_reducer";
import SendScreenIceCandidateReducer from "../send_screen_ice_candidate_reducer";
import SendScreenSdpAnswerReducer from "../send_screen_sdp_answer_reducer";
import SendScreenSdpOfferReducer from "../send_screen_sdp_offer_reducer";
import SendVoiceIceCandidateReducer from "../send_voice_ice_candidate_reducer";
import SendVoiceSdpAnswerReducer from "../send_voice_sdp_answer_reducer";
import SendVoiceSdpOfferReducer from "../send_voice_sdp_offer_reducer";
import SetNameReducer from "../set_name_reducer";
import SetSharingScreenReducer from "../set_sharing_screen_reducer";
import SetTalkingReducer from "../set_talking_reducer";
@@ -34,13 +37,25 @@ export type LeaveServerParams = __Infer<typeof LeaveServerReducer>;
export type LeaveVoiceParams = __Infer<typeof LeaveVoiceReducer>;
export type LoginParams = __Infer<typeof LoginReducer>;
export type RegisterParams = __Infer<typeof RegisterReducer>;
export type SendIceCandidateParams = __Infer<typeof SendIceCandidateReducer>;
export type SendMessageParams = __Infer<typeof SendMessageReducer>;
export type SendSdpAnswerParams = __Infer<typeof SendSdpAnswerReducer>;
export type SendSdpOfferParams = __Infer<typeof SendSdpOfferReducer>;
export type SendScreenIceCandidateParams = __Infer<
typeof SendScreenIceCandidateReducer
>;
export type SendScreenSdpAnswerParams = __Infer<
typeof SendScreenSdpAnswerReducer
>;
export type SendScreenSdpOfferParams = __Infer<
typeof SendScreenSdpOfferReducer
>;
export type SendVoiceIceCandidateParams = __Infer<
typeof SendVoiceIceCandidateReducer
>;
export type SendVoiceSdpAnswerParams = __Infer<
typeof SendVoiceSdpAnswerReducer
>;
export type SendVoiceSdpOfferParams = __Infer<typeof SendVoiceSdpOfferReducer>;
export type SetNameParams = __Infer<typeof SetNameReducer>;
export type SetSharingScreenParams = __Infer<typeof SetSharingScreenReducer>;
export type SetTalkingParams = __Infer<typeof SetTalkingReducer>;
export type StartWatchingParams = __Infer<typeof StartWatchingReducer>;
export type StopWatchingParams = __Infer<typeof StopWatchingReducer>;
+19
View File
@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
id: __t.u64().primaryKey(),
sender: __t.identity(),
receiver: __t.identity(),
candidate: __t.string(),
channelId: __t.u64().name("channel_id"),
});
+19
View File
@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
id: __t.u64().primaryKey(),
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64().name("channel_id"),
});
+19
View File
@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
id: __t.u64().primaryKey(),
sender: __t.identity(),
receiver: __t.identity(),
sdp: __t.string(),
channelId: __t.u64().name("channel_id"),
});
+1 -1
View File
@@ -1 +1 @@
import '@testing-library/jest-dom';
import "@testing-library/jest-dom";
+4 -7
View File
@@ -1,13 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import basicSsl from '@vitejs/plugin-basic-ssl';
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import basicSsl from "@vitejs/plugin-basic-ssl";
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
basicSsl(),
],
plugins: [react(), basicSsl()],
server: {
https: true,
},
+4 -4
View File
@@ -1,12 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom', // or "node" if you're not testing DOM
setupFiles: './src/setupTests.ts',
environment: "jsdom", // or "node" if you're not testing DOM
setupFiles: "./src/setupTests.ts",
testTimeout: 15_000, // give extra time for real connections
hookTimeout: 15_000,
},