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