screen sharing working
This commit is contained in:
+225
-151
@@ -8,12 +8,12 @@
|
||||
|
||||
## Language-Specific Rules
|
||||
|
||||
| Language | Rule File |
|
||||
|----------|-----------|
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` |
|
||||
| Language | Rule File |
|
||||
| ----------------------- | ---------------------------------------- |
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **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
|
||||
|
||||
SpacetimeDB automatically creates indexes for:
|
||||
|
||||
- Primary key columns
|
||||
- Columns marked as unique
|
||||
|
||||
@@ -52,6 +53,7 @@ You can add explicit indexes on non-unique columns for query performance.
|
||||
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
|
||||
|
||||
**Schema ↔ Code coupling:**
|
||||
|
||||
- Your query code references indexes by name
|
||||
- If you add/remove/rename an index in the schema, update all code that uses it
|
||||
- Removing an index without updating queries causes runtime errors
|
||||
@@ -85,7 +87,7 @@ spacetime logs <db-name>
|
||||
## Deployment
|
||||
|
||||
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
|
||||
- The default server marked by *** in `spacetime server list` should be used when publishing
|
||||
- The default server marked by \*\*\* in `spacetime server list` should be used when publishing
|
||||
- If the default server is maincloud you should publish to maincloud
|
||||
- Publishing to maincloud is free of charge
|
||||
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
|
||||
@@ -110,7 +112,6 @@ spacetime logs <db-name>
|
||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
|
||||
|
||||
|
||||
# SpacetimeDB TypeScript SDK
|
||||
|
||||
## ⛔ HALLUCINATED APIs — DO NOT USE
|
||||
@@ -139,11 +140,11 @@ tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object!
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT IMPORTS
|
||||
import { DbConnection, tables } from './module_bindings'; // Generated!
|
||||
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
|
||||
import { DbConnection, tables } from "./module_bindings"; // Generated!
|
||||
import { SpacetimeDBProvider, useTable, Identity } from "spacetimedb/react";
|
||||
|
||||
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
|
||||
conn.reducers.doSomething({ value: 'test' });
|
||||
conn.reducers.doSomething({ value: "test" });
|
||||
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
|
||||
|
||||
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
|
||||
@@ -151,6 +152,7 @@ const [items, isLoading] = useTable(tables.item);
|
||||
```
|
||||
|
||||
### ⛔ DO NOT:
|
||||
|
||||
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
|
||||
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
|
||||
|
||||
@@ -160,40 +162,40 @@ const [items, isLoading] = useTable(tables.item);
|
||||
|
||||
### Server-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| Missing `package.json` | Create `package.json` | "could not detect language" |
|
||||
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
|
||||
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
|
||||
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error |
|
||||
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
|
||||
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
|
||||
| `.filter()` on unique column | `.find()` on unique column | TypeError |
|
||||
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
|
||||
| `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" |
|
||||
| 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" |
|
||||
| `.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" |
|
||||
| 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" |
|
||||
| `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" |
|
||||
| `.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.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable |
|
||||
| Wrong | Right | Error |
|
||||
| ------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Missing `package.json` | Create `package.json` | "could not detect language" |
|
||||
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
|
||||
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
|
||||
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error |
|
||||
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
|
||||
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
|
||||
| `.filter()` on unique column | `.find()` on unique column | TypeError |
|
||||
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
|
||||
| `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" |
|
||||
| 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" |
|
||||
| `.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" |
|
||||
| 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" |
|
||||
| `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" |
|
||||
| `.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.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable |
|
||||
|
||||
### Client-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
|
||||
| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
|
||||
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
|
||||
| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring |
|
||||
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
|
||||
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
|
||||
| Wrong | Right | Error |
|
||||
| ------------------------------------- | ------------------------------------------- | ----------------------- |
|
||||
| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
|
||||
| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
|
||||
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
|
||||
| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring |
|
||||
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
|
||||
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
|
||||
|
||||
---
|
||||
|
||||
@@ -202,62 +204,77 @@ const [items, isLoading] = useTable(tables.item);
|
||||
**`table()` takes TWO arguments: `table(OPTIONS, COLUMNS)`**
|
||||
|
||||
```typescript
|
||||
import { schema, table, t } from 'spacetimedb/server';
|
||||
import { schema, table, t } from "spacetimedb/server";
|
||||
|
||||
// ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error
|
||||
export const Task = table({ name: 'task' }, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] // ❌ WRONG!
|
||||
});
|
||||
export const Task = table(
|
||||
{ name: "task" },
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }], // ❌ WRONG!
|
||||
},
|
||||
);
|
||||
|
||||
// ✅ RIGHT — indexes in OPTIONS (first argument)
|
||||
export const Task = table({
|
||||
name: 'task',
|
||||
public: true,
|
||||
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
|
||||
}, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
title: t.string(),
|
||||
createdAt: t.timestamp(),
|
||||
});
|
||||
export const Task = table(
|
||||
{
|
||||
name: "task",
|
||||
public: true,
|
||||
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
title: t.string(),
|
||||
createdAt: t.timestamp(),
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Column types
|
||||
|
||||
```typescript
|
||||
t.identity() // User identity (primary key for per-user tables)
|
||||
t.u64() // Unsigned 64-bit integer (use for IDs)
|
||||
t.string() // Text
|
||||
t.bool() // Boolean
|
||||
t.timestamp() // Timestamp (use ctx.timestamp for current time)
|
||||
t.scheduleAt() // For scheduled tables only
|
||||
t.identity(); // User identity (primary key for per-user tables)
|
||||
t.u64(); // Unsigned 64-bit integer (use for IDs)
|
||||
t.string(); // Text
|
||||
t.bool(); // Boolean
|
||||
t.timestamp(); // Timestamp (use ctx.timestamp for current time)
|
||||
t.scheduleAt(); // For scheduled tables only
|
||||
|
||||
// Product types (nested objects) — use t.object, NOT t.struct
|
||||
const Point = t.object('Point', { x: t.i32(), y: t.i32() });
|
||||
const Point = t.object("Point", { x: t.i32(), y: t.i32() });
|
||||
|
||||
// Sum types (tagged unions) — use t.enum, NOT t.sum
|
||||
const Shape = t.enum('Shape', { circle: t.i32(), rectangle: Point });
|
||||
const Shape = t.enum("Shape", { circle: t.i32(), rectangle: Point });
|
||||
// Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } }
|
||||
|
||||
// Modifiers
|
||||
t.string().optional() // Nullable
|
||||
t.u64().primaryKey() // Primary key
|
||||
t.u64().primaryKey().autoInc() // Auto-increment primary key
|
||||
t.string().optional(); // Nullable
|
||||
t.u64().primaryKey(); // Primary key
|
||||
t.u64().primaryKey().autoInc(); // Auto-increment primary key
|
||||
```
|
||||
|
||||
> ⚠️ **BIGINT SYNTAX:** All `u64`, `i64`, and ID fields use JavaScript BigInt.
|
||||
>
|
||||
> - Literals: `0n`, `1n`, `100n` (NOT `0`, `1`, `100`)
|
||||
> - Comparisons: `row.id === 5n` (NOT `row.id === 5`)
|
||||
> - Arithmetic: `row.count + 1n` (NOT `row.count + 1`)
|
||||
|
||||
### Auto-increment placeholder
|
||||
|
||||
```typescript
|
||||
// ✅ MUST provide 0n placeholder for auto-inc fields
|
||||
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title: 'New', createdAt: ctx.timestamp });
|
||||
ctx.db.task.insert({
|
||||
id: 0n,
|
||||
ownerId: ctx.sender,
|
||||
title: "New",
|
||||
createdAt: ctx.timestamp,
|
||||
});
|
||||
```
|
||||
|
||||
### Insert returns ROW, not ID
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
const id = ctx.db.task.insert({ ... });
|
||||
@@ -268,14 +285,15 @@ const newId = row.id; // Extract .id from returned row
|
||||
```
|
||||
|
||||
### Schema export (CRITICAL)
|
||||
|
||||
```typescript
|
||||
// At end of schema.ts — schema() takes exactly ONE argument: an object
|
||||
const spacetimedb = schema({ table1, table2, table3 });
|
||||
export default spacetimedb;
|
||||
|
||||
// ❌ WRONG — never pass tables directly or as multiple args
|
||||
schema(myTable); // WRONG!
|
||||
schema(t1, t2, t3); // WRONG!
|
||||
schema(myTable); // 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
|
||||
for (const m of ctx.db.roomMember.iter()) {
|
||||
if (m.roomId === roomId) { /* ... */ }
|
||||
if (m.roomId === roomId) {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -302,24 +322,31 @@ for (const m of ctx.db.roomMember.iter()) {
|
||||
|
||||
```typescript
|
||||
// In table OPTIONS (first argument), not columns
|
||||
export const Message = table({
|
||||
name: 'message',
|
||||
public: true,
|
||||
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
|
||||
}, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
roomId: t.u64(),
|
||||
// ...
|
||||
});
|
||||
export const Message = table(
|
||||
{
|
||||
name: "message",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ name: "message_room_id", algorithm: "btree", columns: ["roomId"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
roomId: t.u64(),
|
||||
// ...
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Naming conventions
|
||||
|
||||
**Table names — automatic transformation:**
|
||||
|
||||
- Schema: `table({ name: 'my_messages' })`
|
||||
- Access: `ctx.db.myMessages` (automatic snake_case → camelCase)
|
||||
|
||||
**Index names — NO transformation, use EXACTLY as defined:**
|
||||
|
||||
```typescript
|
||||
// Schema definition
|
||||
indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }]
|
||||
@@ -335,6 +362,7 @@ ctx.db.canvasMember.canvas_member_canvas_id.filter(...)
|
||||
> ⚠️ **Index names are used VERBATIM** — pick a convention (snake_case or camelCase) and stick with it.
|
||||
|
||||
**Index naming pattern — use `{tableName}_{columnName}`:**
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD — unique names across entire module
|
||||
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
|
||||
@@ -346,10 +374,12 @@ indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT!
|
||||
```
|
||||
|
||||
**Client-side table names:**
|
||||
|
||||
- Check generated `module_bindings/index.ts` for exact export names
|
||||
- Usage: `useTable(tables.MyMessages)` or `tables.myMessages` (varies by SDK version)
|
||||
|
||||
### Filter vs Find
|
||||
|
||||
```typescript
|
||||
// Filter takes VALUE directly, not object — returns iterator
|
||||
const rows = [...ctx.db.task.by_owner.filter(ownerId)];
|
||||
@@ -359,13 +389,16 @@ const row = ctx.db.player.identity.find(ctx.sender);
|
||||
```
|
||||
|
||||
### ⚠️ Multi-column indexes are BROKEN
|
||||
|
||||
```typescript
|
||||
// ❌ DON'T — causes PANIC
|
||||
ctx.db.scores.by_player_level.filter(playerId);
|
||||
|
||||
// ✅ DO — use single-column index + manual filter
|
||||
for (const row of ctx.db.scores.by_player.filter(playerId)) {
|
||||
if (row.level === targetLevel) { /* ... */ }
|
||||
if (row.level === targetLevel) {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -374,6 +407,7 @@ for (const row of ctx.db.scores.by_player.filter(playerId)) {
|
||||
## 4) Reducers
|
||||
|
||||
### Definition syntax (CRITICAL)
|
||||
|
||||
**Reducer name comes from the export — NOT from a string argument.** Use `reducer(params, fn)` or `reducer(fn)`.
|
||||
|
||||
```typescript
|
||||
@@ -403,24 +437,31 @@ spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) =>
|
||||
```
|
||||
|
||||
### Update pattern (CRITICAL)
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT — spread existing row, override specific fields
|
||||
const existing = ctx.db.task.id.find(taskId);
|
||||
if (!existing) throw new SenderError('Task not found');
|
||||
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
|
||||
if (!existing) throw new SenderError("Task not found");
|
||||
ctx.db.task.id.update({
|
||||
...existing,
|
||||
title: newTitle,
|
||||
updatedAt: ctx.timestamp,
|
||||
});
|
||||
|
||||
// ❌ WRONG — partial update nulls out other fields!
|
||||
ctx.db.task.id.update({ id: taskId, title: newTitle });
|
||||
```
|
||||
|
||||
### Delete pattern
|
||||
|
||||
```typescript
|
||||
// Delete by primary key VALUE (not row object)
|
||||
ctx.db.task.id.delete(taskId); // taskId is the u64 value
|
||||
ctx.db.player.identity.delete(ctx.sender); // delete by identity
|
||||
ctx.db.task.id.delete(taskId); // taskId is the u64 value
|
||||
ctx.db.player.identity.delete(ctx.sender); // delete by identity
|
||||
```
|
||||
|
||||
### Lifecycle hooks
|
||||
|
||||
```typescript
|
||||
spacetimedb.clientConnected((ctx) => {
|
||||
// ctx.sender is the connecting identity
|
||||
@@ -433,16 +474,18 @@ spacetimedb.clientDisconnected((ctx) => {
|
||||
```
|
||||
|
||||
### Snake_case to camelCase conversion
|
||||
|
||||
- Server: `export const do_something = spacetimedb.reducer(...)` — name from export
|
||||
- Client: `conn.reducers.doSomething({ ... })`
|
||||
|
||||
### Object syntax required
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - positional
|
||||
conn.reducers.doSomething('value');
|
||||
conn.reducers.doSomething("value");
|
||||
|
||||
// ✅ RIGHT - object
|
||||
conn.reducers.doSomething({ param: 'value' });
|
||||
conn.reducers.doSomething({ param: "value" });
|
||||
```
|
||||
|
||||
---
|
||||
@@ -451,28 +494,34 @@ conn.reducers.doSomething({ param: 'value' });
|
||||
|
||||
```typescript
|
||||
// 1. Define table first (scheduled: () => reducer — pass the exported reducer)
|
||||
export const CleanupJob = table({
|
||||
name: 'cleanup_job',
|
||||
scheduled: () => run_cleanup // reducer defined below
|
||||
}, {
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
targetId: t.u64(), // Your custom data
|
||||
});
|
||||
export const CleanupJob = table(
|
||||
{
|
||||
name: "cleanup_job",
|
||||
scheduled: () => run_cleanup, // reducer defined below
|
||||
},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
targetId: t.u64(), // Your custom data
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Define scheduled reducer (receives full row as arg)
|
||||
export const run_cleanup = spacetimedb.reducer({ arg: CleanupJob.rowType }, (ctx, { arg }) => {
|
||||
// arg.scheduledId, arg.targetId available
|
||||
// Row is auto-deleted after reducer completes
|
||||
});
|
||||
export const run_cleanup = spacetimedb.reducer(
|
||||
{ arg: CleanupJob.rowType },
|
||||
(ctx, { arg }) => {
|
||||
// arg.scheduledId, arg.targetId available
|
||||
// Row is auto-deleted after reducer completes
|
||||
},
|
||||
);
|
||||
|
||||
// Schedule a job
|
||||
import { ScheduleAt } from 'spacetimedb';
|
||||
import { ScheduleAt } from "spacetimedb";
|
||||
const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
|
||||
ctx.db.cleanupJob.insert({
|
||||
scheduledId: 0n,
|
||||
scheduledAt: ScheduleAt.time(futureTime),
|
||||
targetId: someId
|
||||
targetId: someId,
|
||||
});
|
||||
|
||||
// Cancel a job by deleting the row
|
||||
@@ -484,18 +533,21 @@ ctx.db.cleanupJob.scheduledId.delete(jobId);
|
||||
## 6) Timestamps
|
||||
|
||||
### Server-side
|
||||
|
||||
```typescript
|
||||
import { Timestamp, ScheduleAt } from 'spacetimedb';
|
||||
import { Timestamp, ScheduleAt } from "spacetimedb";
|
||||
|
||||
// Current time
|
||||
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
|
||||
|
||||
// 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)
|
||||
|
||||
**Timestamps are objects, not numbers:**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
const date = new Date(row.createdAt);
|
||||
@@ -506,9 +558,10 @@ const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
|
||||
```
|
||||
|
||||
### ScheduleAt on client
|
||||
|
||||
```typescript
|
||||
// ScheduleAt is a tagged union
|
||||
if (scheduleAt.tag === 'Time') {
|
||||
if (scheduleAt.tag === "Time") {
|
||||
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
|
||||
}
|
||||
```
|
||||
@@ -519,32 +572,37 @@ if (scheduleAt.tag === 'Time') {
|
||||
|
||||
**`public: true` exposes ALL rows to ALL clients.**
|
||||
|
||||
| Scenario | Pattern |
|
||||
|----------|---------|
|
||||
| Everyone sees all rows | `public: true` |
|
||||
| Scenario | Pattern |
|
||||
| ------------------------- | ------------------------------------- |
|
||||
| Everyone sees all rows | `public: true` |
|
||||
| Users see only their data | Private table + filtered subscription |
|
||||
|
||||
### Subscription patterns (client-side)
|
||||
|
||||
```typescript
|
||||
// Subscribe to ALL public tables (simplest)
|
||||
conn.subscriptionBuilder().subscribeToAll();
|
||||
|
||||
// Subscribe to specific tables with SQL
|
||||
conn.subscriptionBuilder().subscribe([
|
||||
'SELECT * FROM message',
|
||||
'SELECT * FROM room WHERE is_public = true',
|
||||
]);
|
||||
conn
|
||||
.subscriptionBuilder()
|
||||
.subscribe([
|
||||
"SELECT * FROM message",
|
||||
"SELECT * FROM room WHERE is_public = true",
|
||||
]);
|
||||
|
||||
// Handle subscription lifecycle
|
||||
conn.subscriptionBuilder()
|
||||
.onApplied(() => console.log('Initial data loaded'))
|
||||
.onError((e) => console.error('Subscription failed:', e))
|
||||
conn
|
||||
.subscriptionBuilder()
|
||||
.onApplied(() => console.log("Initial data loaded"))
|
||||
.onError((e) => console.error("Subscription failed:", e))
|
||||
.subscribeToAll();
|
||||
```
|
||||
|
||||
### Private table + view pattern (RECOMMENDED)
|
||||
|
||||
**Views are the recommended approach** for controlling data visibility. They provide:
|
||||
|
||||
- Server-side filtering (reduces network traffic)
|
||||
- Real-time updates when underlying data changes
|
||||
- Full control over what data clients can access
|
||||
@@ -557,28 +615,29 @@ conn.subscriptionBuilder()
|
||||
```typescript
|
||||
// Private table with index on ownerId
|
||||
export const PrivateData = table(
|
||||
{ name: 'private_data',
|
||||
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
|
||||
{
|
||||
name: "private_data",
|
||||
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
secret: t.string()
|
||||
}
|
||||
secret: t.string(),
|
||||
},
|
||||
);
|
||||
|
||||
// ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change)
|
||||
spacetimedb.view(
|
||||
{ name: 'my_data_slow', public: true },
|
||||
{ name: "my_data_slow", public: true },
|
||||
t.array(PrivateData.rowType),
|
||||
(ctx) => [...ctx.db.privateData.iter()] // Works but VERY slow at scale
|
||||
(ctx) => [...ctx.db.privateData.iter()], // Works but VERY slow at scale
|
||||
);
|
||||
|
||||
// ✅ GOOD — index lookup enables targeted invalidation
|
||||
spacetimedb.view(
|
||||
{ name: 'my_data', public: true },
|
||||
{ name: "my_data", public: true },
|
||||
t.array(PrivateData.rowType),
|
||||
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)]
|
||||
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)],
|
||||
);
|
||||
```
|
||||
|
||||
@@ -588,32 +647,40 @@ spacetimedb.view(
|
||||
// Query-builder views return a query; the SQL engine maintains the result incrementally.
|
||||
// This can scan the whole table if needed (e.g. leaderboard-style queries).
|
||||
spacetimedb.anonymousView(
|
||||
{ name: 'top_players', public: true },
|
||||
{ name: "top_players", public: true },
|
||||
t.array(Player.rowType),
|
||||
(ctx) =>
|
||||
ctx.from.player
|
||||
.where(p => p.score.gt(1000))
|
||||
(ctx) => ctx.from.player.where((p) => p.score.gt(1000)),
|
||||
);
|
||||
```
|
||||
|
||||
### ViewContext vs AnonymousViewContext
|
||||
|
||||
```typescript
|
||||
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
|
||||
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
|
||||
return [...ctx.db.item.by_owner.filter(ctx.sender)];
|
||||
});
|
||||
spacetimedb.view(
|
||||
{ name: "my_items", public: true },
|
||||
t.array(Item.rowType),
|
||||
(ctx) => {
|
||||
return [...ctx.db.item.by_owner.filter(ctx.sender)];
|
||||
},
|
||||
);
|
||||
|
||||
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
|
||||
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
|
||||
return [...ctx.db.player.by_score.filter(/* top scores */)];
|
||||
});
|
||||
spacetimedb.anonymousView(
|
||||
{ name: "leaderboard", public: true },
|
||||
t.array(LeaderboardRow),
|
||||
(ctx) => {
|
||||
return [...ctx.db.player.by_score.filter(/* top scores */)];
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Views require explicit subscription:**
|
||||
|
||||
```typescript
|
||||
conn.subscriptionBuilder().subscribe([
|
||||
'SELECT * FROM public_table',
|
||||
'SELECT * FROM my_data', // Views need explicit SQL!
|
||||
"SELECT * FROM public_table",
|
||||
"SELECT * FROM my_data", // Views need explicit SQL!
|
||||
]);
|
||||
```
|
||||
|
||||
@@ -622,16 +689,18 @@ conn.subscriptionBuilder().subscribe([
|
||||
## 8) React Integration
|
||||
|
||||
### Key patterns
|
||||
|
||||
```typescript
|
||||
// Memoize connectionBuilder to prevent reconnects on re-render
|
||||
const builder = useMemo(() =>
|
||||
DbConnection.builder()
|
||||
.withUri(SPACETIMEDB_URI)
|
||||
.withDatabaseName(MODULE_NAME)
|
||||
.withToken(localStorage.getItem('auth_token') || undefined)
|
||||
.onConnect(onConnect)
|
||||
.onConnectError(onConnectError),
|
||||
[] // Empty deps - only create once
|
||||
const builder = useMemo(
|
||||
() =>
|
||||
DbConnection.builder()
|
||||
.withUri(SPACETIMEDB_URI)
|
||||
.withDatabaseName(MODULE_NAME)
|
||||
.withToken(localStorage.getItem("auth_token") || undefined)
|
||||
.onConnect(onConnect)
|
||||
.onConnectError(onConnectError),
|
||||
[], // Empty deps - only create once
|
||||
);
|
||||
|
||||
// useTable returns tuple [rows, isLoading]
|
||||
@@ -650,17 +719,18 @@ const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();
|
||||
⚠️ Procedures are currently in beta. API may change.
|
||||
|
||||
### Defining a procedure
|
||||
|
||||
**Procedure name comes from the export — NOT from a string argument.** Use `procedure(params, ret, fn)` or `procedure(ret, fn)`.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT — export const name = spacetimedb.procedure(params, ret, fn)
|
||||
export const fetch_external_data = spacetimedb.procedure(
|
||||
{ url: t.string() },
|
||||
t.string(), // return type
|
||||
t.string(), // return type
|
||||
(ctx, { url }) => {
|
||||
const response = ctx.http.fetch(url);
|
||||
return response.text();
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
@@ -692,18 +762,20 @@ spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
|
||||
```
|
||||
|
||||
### Key differences from reducers
|
||||
| Reducers | Procedures |
|
||||
|----------|------------|
|
||||
|
||||
| Reducers | Procedures |
|
||||
| --------------------------- | ------------------------------------- |
|
||||
| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` |
|
||||
| Automatic transaction | Manual transaction management |
|
||||
| No HTTP/network | `ctx.http.fetch()` available |
|
||||
| No return values to caller | Can return data to caller |
|
||||
| Automatic transaction | Manual transaction management |
|
||||
| No HTTP/network | `ctx.http.fetch()` available |
|
||||
| No return values to caller | Can return data to caller |
|
||||
|
||||
---
|
||||
|
||||
## 10) Project Structure
|
||||
|
||||
### Server (`backend/spacetimedb/`)
|
||||
|
||||
```
|
||||
src/schema.ts → Tables, export spacetimedb
|
||||
src/index.ts → Reducers, lifecycle, import schema
|
||||
@@ -712,12 +784,14 @@ tsconfig.json → Standard config
|
||||
```
|
||||
|
||||
### Avoiding circular imports
|
||||
|
||||
```
|
||||
schema.ts → defines tables AND exports spacetimedb
|
||||
index.ts → imports spacetimedb from ./schema, defines reducers
|
||||
```
|
||||
|
||||
### Client (`client/`)
|
||||
|
||||
```
|
||||
src/module_bindings/ → Generated (spacetime generate)
|
||||
src/main.tsx → Provider, connection setup
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
## Language-Specific Rules
|
||||
|
||||
| Language | Rule File |
|
||||
|----------|-----------|
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` |
|
||||
| Language | Rule File |
|
||||
| ----------------------- | ---------------------------------------- |
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **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
|
||||
|
||||
SpacetimeDB automatically creates indexes for:
|
||||
|
||||
- Primary key columns
|
||||
- Columns marked as unique
|
||||
|
||||
@@ -52,6 +53,7 @@ You can add explicit indexes on non-unique columns for query performance.
|
||||
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
|
||||
|
||||
**Schema ↔ Code coupling:**
|
||||
|
||||
- Your query code references indexes by name
|
||||
- If you add/remove/rename an index in the schema, update all code that uses it
|
||||
- Removing an index without updating queries causes runtime errors
|
||||
@@ -85,7 +87,7 @@ spacetime logs <db-name>
|
||||
## Deployment
|
||||
|
||||
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
|
||||
- The default server marked by *** in `spacetime server list` should be used when publishing
|
||||
- The default server marked by \*\*\* in `spacetime server list` should be used when publishing
|
||||
- If the default server is maincloud you should publish to maincloud
|
||||
- Publishing to maincloud is free of charge
|
||||
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
|
||||
@@ -110,7 +112,6 @@ spacetime logs <db-name>
|
||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
|
||||
|
||||
|
||||
# SpacetimeDB TypeScript SDK
|
||||
|
||||
## ⛔ HALLUCINATED APIs — DO NOT USE
|
||||
@@ -139,11 +140,11 @@ tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object!
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT IMPORTS
|
||||
import { DbConnection, tables } from './module_bindings'; // Generated!
|
||||
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
|
||||
import { DbConnection, tables } from "./module_bindings"; // Generated!
|
||||
import { SpacetimeDBProvider, useTable, Identity } from "spacetimedb/react";
|
||||
|
||||
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
|
||||
conn.reducers.doSomething({ value: 'test' });
|
||||
conn.reducers.doSomething({ value: "test" });
|
||||
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
|
||||
|
||||
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
|
||||
@@ -151,6 +152,7 @@ const [items, isLoading] = useTable(tables.item);
|
||||
```
|
||||
|
||||
### ⛔ DO NOT:
|
||||
|
||||
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
|
||||
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
|
||||
|
||||
@@ -160,40 +162,40 @@ const [items, isLoading] = useTable(tables.item);
|
||||
|
||||
### Server-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| Missing `package.json` | Create `package.json` | "could not detect language" |
|
||||
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
|
||||
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
|
||||
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error |
|
||||
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
|
||||
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
|
||||
| `.filter()` on unique column | `.find()` on unique column | TypeError |
|
||||
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
|
||||
| `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" |
|
||||
| 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" |
|
||||
| `.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" |
|
||||
| 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" |
|
||||
| `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" |
|
||||
| `.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.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable |
|
||||
| Wrong | Right | Error |
|
||||
| ------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Missing `package.json` | Create `package.json` | "could not detect language" |
|
||||
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
|
||||
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
|
||||
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error |
|
||||
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
|
||||
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
|
||||
| `.filter()` on unique column | `.find()` on unique column | TypeError |
|
||||
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
|
||||
| `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" |
|
||||
| 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" |
|
||||
| `.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" |
|
||||
| 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" |
|
||||
| `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" |
|
||||
| `.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.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable |
|
||||
|
||||
### Client-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
|
||||
| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
|
||||
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
|
||||
| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring |
|
||||
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
|
||||
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
|
||||
| Wrong | Right | Error |
|
||||
| ------------------------------------- | ------------------------------------------- | ----------------------- |
|
||||
| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
|
||||
| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
|
||||
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
|
||||
| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring |
|
||||
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
|
||||
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
|
||||
|
||||
---
|
||||
|
||||
@@ -202,62 +204,77 @@ const [items, isLoading] = useTable(tables.item);
|
||||
**`table()` takes TWO arguments: `table(OPTIONS, COLUMNS)`**
|
||||
|
||||
```typescript
|
||||
import { schema, table, t } from 'spacetimedb/server';
|
||||
import { schema, table, t } from "spacetimedb/server";
|
||||
|
||||
// ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error
|
||||
export const Task = table({ name: 'task' }, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] // ❌ WRONG!
|
||||
});
|
||||
export const Task = table(
|
||||
{ name: "task" },
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }], // ❌ WRONG!
|
||||
},
|
||||
);
|
||||
|
||||
// ✅ RIGHT — indexes in OPTIONS (first argument)
|
||||
export const Task = table({
|
||||
name: 'task',
|
||||
public: true,
|
||||
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
|
||||
}, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
title: t.string(),
|
||||
createdAt: t.timestamp(),
|
||||
});
|
||||
export const Task = table(
|
||||
{
|
||||
name: "task",
|
||||
public: true,
|
||||
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
title: t.string(),
|
||||
createdAt: t.timestamp(),
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Column types
|
||||
|
||||
```typescript
|
||||
t.identity() // User identity (primary key for per-user tables)
|
||||
t.u64() // Unsigned 64-bit integer (use for IDs)
|
||||
t.string() // Text
|
||||
t.bool() // Boolean
|
||||
t.timestamp() // Timestamp (use ctx.timestamp for current time)
|
||||
t.scheduleAt() // For scheduled tables only
|
||||
t.identity(); // User identity (primary key for per-user tables)
|
||||
t.u64(); // Unsigned 64-bit integer (use for IDs)
|
||||
t.string(); // Text
|
||||
t.bool(); // Boolean
|
||||
t.timestamp(); // Timestamp (use ctx.timestamp for current time)
|
||||
t.scheduleAt(); // For scheduled tables only
|
||||
|
||||
// Product types (nested objects) — use t.object, NOT t.struct
|
||||
const Point = t.object('Point', { x: t.i32(), y: t.i32() });
|
||||
const Point = t.object("Point", { x: t.i32(), y: t.i32() });
|
||||
|
||||
// Sum types (tagged unions) — use t.enum, NOT t.sum
|
||||
const Shape = t.enum('Shape', { circle: t.i32(), rectangle: Point });
|
||||
const Shape = t.enum("Shape", { circle: t.i32(), rectangle: Point });
|
||||
// Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } }
|
||||
|
||||
// Modifiers
|
||||
t.string().optional() // Nullable
|
||||
t.u64().primaryKey() // Primary key
|
||||
t.u64().primaryKey().autoInc() // Auto-increment primary key
|
||||
t.string().optional(); // Nullable
|
||||
t.u64().primaryKey(); // Primary key
|
||||
t.u64().primaryKey().autoInc(); // Auto-increment primary key
|
||||
```
|
||||
|
||||
> ⚠️ **BIGINT SYNTAX:** All `u64`, `i64`, and ID fields use JavaScript BigInt.
|
||||
>
|
||||
> - Literals: `0n`, `1n`, `100n` (NOT `0`, `1`, `100`)
|
||||
> - Comparisons: `row.id === 5n` (NOT `row.id === 5`)
|
||||
> - Arithmetic: `row.count + 1n` (NOT `row.count + 1`)
|
||||
|
||||
### Auto-increment placeholder
|
||||
|
||||
```typescript
|
||||
// ✅ MUST provide 0n placeholder for auto-inc fields
|
||||
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title: 'New', createdAt: ctx.timestamp });
|
||||
ctx.db.task.insert({
|
||||
id: 0n,
|
||||
ownerId: ctx.sender,
|
||||
title: "New",
|
||||
createdAt: ctx.timestamp,
|
||||
});
|
||||
```
|
||||
|
||||
### Insert returns ROW, not ID
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
const id = ctx.db.task.insert({ ... });
|
||||
@@ -268,14 +285,15 @@ const newId = row.id; // Extract .id from returned row
|
||||
```
|
||||
|
||||
### Schema export (CRITICAL)
|
||||
|
||||
```typescript
|
||||
// At end of schema.ts — schema() takes exactly ONE argument: an object
|
||||
const spacetimedb = schema({ table1, table2, table3 });
|
||||
export default spacetimedb;
|
||||
|
||||
// ❌ WRONG — never pass tables directly or as multiple args
|
||||
schema(myTable); // WRONG!
|
||||
schema(t1, t2, t3); // WRONG!
|
||||
schema(myTable); // 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
|
||||
for (const m of ctx.db.roomMember.iter()) {
|
||||
if (m.roomId === roomId) { /* ... */ }
|
||||
if (m.roomId === roomId) {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -302,24 +322,31 @@ for (const m of ctx.db.roomMember.iter()) {
|
||||
|
||||
```typescript
|
||||
// In table OPTIONS (first argument), not columns
|
||||
export const Message = table({
|
||||
name: 'message',
|
||||
public: true,
|
||||
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
|
||||
}, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
roomId: t.u64(),
|
||||
// ...
|
||||
});
|
||||
export const Message = table(
|
||||
{
|
||||
name: "message",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ name: "message_room_id", algorithm: "btree", columns: ["roomId"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
roomId: t.u64(),
|
||||
// ...
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Naming conventions
|
||||
|
||||
**Table names — automatic transformation:**
|
||||
|
||||
- Schema: `table({ name: 'my_messages' })`
|
||||
- Access: `ctx.db.myMessages` (automatic snake_case → camelCase)
|
||||
|
||||
**Index names — NO transformation, use EXACTLY as defined:**
|
||||
|
||||
```typescript
|
||||
// Schema definition
|
||||
indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }]
|
||||
@@ -335,6 +362,7 @@ ctx.db.canvasMember.canvas_member_canvas_id.filter(...)
|
||||
> ⚠️ **Index names are used VERBATIM** — pick a convention (snake_case or camelCase) and stick with it.
|
||||
|
||||
**Index naming pattern — use `{tableName}_{columnName}`:**
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD — unique names across entire module
|
||||
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
|
||||
@@ -346,10 +374,12 @@ indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT!
|
||||
```
|
||||
|
||||
**Client-side table names:**
|
||||
|
||||
- Check generated `module_bindings/index.ts` for exact export names
|
||||
- Usage: `useTable(tables.MyMessages)` or `tables.myMessages` (varies by SDK version)
|
||||
|
||||
### Filter vs Find
|
||||
|
||||
```typescript
|
||||
// Filter takes VALUE directly, not object — returns iterator
|
||||
const rows = [...ctx.db.task.by_owner.filter(ownerId)];
|
||||
@@ -359,13 +389,16 @@ const row = ctx.db.player.identity.find(ctx.sender);
|
||||
```
|
||||
|
||||
### ⚠️ Multi-column indexes are BROKEN
|
||||
|
||||
```typescript
|
||||
// ❌ DON'T — causes PANIC
|
||||
ctx.db.scores.by_player_level.filter(playerId);
|
||||
|
||||
// ✅ DO — use single-column index + manual filter
|
||||
for (const row of ctx.db.scores.by_player.filter(playerId)) {
|
||||
if (row.level === targetLevel) { /* ... */ }
|
||||
if (row.level === targetLevel) {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -374,6 +407,7 @@ for (const row of ctx.db.scores.by_player.filter(playerId)) {
|
||||
## 4) Reducers
|
||||
|
||||
### Definition syntax (CRITICAL)
|
||||
|
||||
**Reducer name comes from the export — NOT from a string argument.** Use `reducer(params, fn)` or `reducer(fn)`.
|
||||
|
||||
```typescript
|
||||
@@ -403,24 +437,31 @@ spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) =>
|
||||
```
|
||||
|
||||
### Update pattern (CRITICAL)
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT — spread existing row, override specific fields
|
||||
const existing = ctx.db.task.id.find(taskId);
|
||||
if (!existing) throw new SenderError('Task not found');
|
||||
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
|
||||
if (!existing) throw new SenderError("Task not found");
|
||||
ctx.db.task.id.update({
|
||||
...existing,
|
||||
title: newTitle,
|
||||
updatedAt: ctx.timestamp,
|
||||
});
|
||||
|
||||
// ❌ WRONG — partial update nulls out other fields!
|
||||
ctx.db.task.id.update({ id: taskId, title: newTitle });
|
||||
```
|
||||
|
||||
### Delete pattern
|
||||
|
||||
```typescript
|
||||
// Delete by primary key VALUE (not row object)
|
||||
ctx.db.task.id.delete(taskId); // taskId is the u64 value
|
||||
ctx.db.player.identity.delete(ctx.sender); // delete by identity
|
||||
ctx.db.task.id.delete(taskId); // taskId is the u64 value
|
||||
ctx.db.player.identity.delete(ctx.sender); // delete by identity
|
||||
```
|
||||
|
||||
### Lifecycle hooks
|
||||
|
||||
```typescript
|
||||
spacetimedb.clientConnected((ctx) => {
|
||||
// ctx.sender is the connecting identity
|
||||
@@ -433,16 +474,18 @@ spacetimedb.clientDisconnected((ctx) => {
|
||||
```
|
||||
|
||||
### Snake_case to camelCase conversion
|
||||
|
||||
- Server: `export const do_something = spacetimedb.reducer(...)` — name from export
|
||||
- Client: `conn.reducers.doSomething({ ... })`
|
||||
|
||||
### Object syntax required
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - positional
|
||||
conn.reducers.doSomething('value');
|
||||
conn.reducers.doSomething("value");
|
||||
|
||||
// ✅ RIGHT - object
|
||||
conn.reducers.doSomething({ param: 'value' });
|
||||
conn.reducers.doSomething({ param: "value" });
|
||||
```
|
||||
|
||||
---
|
||||
@@ -451,28 +494,34 @@ conn.reducers.doSomething({ param: 'value' });
|
||||
|
||||
```typescript
|
||||
// 1. Define table first (scheduled: () => reducer — pass the exported reducer)
|
||||
export const CleanupJob = table({
|
||||
name: 'cleanup_job',
|
||||
scheduled: () => run_cleanup // reducer defined below
|
||||
}, {
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
targetId: t.u64(), // Your custom data
|
||||
});
|
||||
export const CleanupJob = table(
|
||||
{
|
||||
name: "cleanup_job",
|
||||
scheduled: () => run_cleanup, // reducer defined below
|
||||
},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
targetId: t.u64(), // Your custom data
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Define scheduled reducer (receives full row as arg)
|
||||
export const run_cleanup = spacetimedb.reducer({ arg: CleanupJob.rowType }, (ctx, { arg }) => {
|
||||
// arg.scheduledId, arg.targetId available
|
||||
// Row is auto-deleted after reducer completes
|
||||
});
|
||||
export const run_cleanup = spacetimedb.reducer(
|
||||
{ arg: CleanupJob.rowType },
|
||||
(ctx, { arg }) => {
|
||||
// arg.scheduledId, arg.targetId available
|
||||
// Row is auto-deleted after reducer completes
|
||||
},
|
||||
);
|
||||
|
||||
// Schedule a job
|
||||
import { ScheduleAt } from 'spacetimedb';
|
||||
import { ScheduleAt } from "spacetimedb";
|
||||
const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
|
||||
ctx.db.cleanupJob.insert({
|
||||
scheduledId: 0n,
|
||||
scheduledAt: ScheduleAt.time(futureTime),
|
||||
targetId: someId
|
||||
targetId: someId,
|
||||
});
|
||||
|
||||
// Cancel a job by deleting the row
|
||||
@@ -484,18 +533,21 @@ ctx.db.cleanupJob.scheduledId.delete(jobId);
|
||||
## 6) Timestamps
|
||||
|
||||
### Server-side
|
||||
|
||||
```typescript
|
||||
import { Timestamp, ScheduleAt } from 'spacetimedb';
|
||||
import { Timestamp, ScheduleAt } from "spacetimedb";
|
||||
|
||||
// Current time
|
||||
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
|
||||
|
||||
// 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)
|
||||
|
||||
**Timestamps are objects, not numbers:**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
const date = new Date(row.createdAt);
|
||||
@@ -506,9 +558,10 @@ const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
|
||||
```
|
||||
|
||||
### ScheduleAt on client
|
||||
|
||||
```typescript
|
||||
// ScheduleAt is a tagged union
|
||||
if (scheduleAt.tag === 'Time') {
|
||||
if (scheduleAt.tag === "Time") {
|
||||
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
|
||||
}
|
||||
```
|
||||
@@ -519,32 +572,37 @@ if (scheduleAt.tag === 'Time') {
|
||||
|
||||
**`public: true` exposes ALL rows to ALL clients.**
|
||||
|
||||
| Scenario | Pattern |
|
||||
|----------|---------|
|
||||
| Everyone sees all rows | `public: true` |
|
||||
| Scenario | Pattern |
|
||||
| ------------------------- | ------------------------------------- |
|
||||
| Everyone sees all rows | `public: true` |
|
||||
| Users see only their data | Private table + filtered subscription |
|
||||
|
||||
### Subscription patterns (client-side)
|
||||
|
||||
```typescript
|
||||
// Subscribe to ALL public tables (simplest)
|
||||
conn.subscriptionBuilder().subscribeToAll();
|
||||
|
||||
// Subscribe to specific tables with SQL
|
||||
conn.subscriptionBuilder().subscribe([
|
||||
'SELECT * FROM message',
|
||||
'SELECT * FROM room WHERE is_public = true',
|
||||
]);
|
||||
conn
|
||||
.subscriptionBuilder()
|
||||
.subscribe([
|
||||
"SELECT * FROM message",
|
||||
"SELECT * FROM room WHERE is_public = true",
|
||||
]);
|
||||
|
||||
// Handle subscription lifecycle
|
||||
conn.subscriptionBuilder()
|
||||
.onApplied(() => console.log('Initial data loaded'))
|
||||
.onError((e) => console.error('Subscription failed:', e))
|
||||
conn
|
||||
.subscriptionBuilder()
|
||||
.onApplied(() => console.log("Initial data loaded"))
|
||||
.onError((e) => console.error("Subscription failed:", e))
|
||||
.subscribeToAll();
|
||||
```
|
||||
|
||||
### Private table + view pattern (RECOMMENDED)
|
||||
|
||||
**Views are the recommended approach** for controlling data visibility. They provide:
|
||||
|
||||
- Server-side filtering (reduces network traffic)
|
||||
- Real-time updates when underlying data changes
|
||||
- Full control over what data clients can access
|
||||
@@ -557,28 +615,29 @@ conn.subscriptionBuilder()
|
||||
```typescript
|
||||
// Private table with index on ownerId
|
||||
export const PrivateData = table(
|
||||
{ name: 'private_data',
|
||||
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
|
||||
{
|
||||
name: "private_data",
|
||||
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
secret: t.string()
|
||||
}
|
||||
secret: t.string(),
|
||||
},
|
||||
);
|
||||
|
||||
// ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change)
|
||||
spacetimedb.view(
|
||||
{ name: 'my_data_slow', public: true },
|
||||
{ name: "my_data_slow", public: true },
|
||||
t.array(PrivateData.rowType),
|
||||
(ctx) => [...ctx.db.privateData.iter()] // Works but VERY slow at scale
|
||||
(ctx) => [...ctx.db.privateData.iter()], // Works but VERY slow at scale
|
||||
);
|
||||
|
||||
// ✅ GOOD — index lookup enables targeted invalidation
|
||||
spacetimedb.view(
|
||||
{ name: 'my_data', public: true },
|
||||
{ name: "my_data", public: true },
|
||||
t.array(PrivateData.rowType),
|
||||
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)]
|
||||
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)],
|
||||
);
|
||||
```
|
||||
|
||||
@@ -588,32 +647,40 @@ spacetimedb.view(
|
||||
// Query-builder views return a query; the SQL engine maintains the result incrementally.
|
||||
// This can scan the whole table if needed (e.g. leaderboard-style queries).
|
||||
spacetimedb.anonymousView(
|
||||
{ name: 'top_players', public: true },
|
||||
{ name: "top_players", public: true },
|
||||
t.array(Player.rowType),
|
||||
(ctx) =>
|
||||
ctx.from.player
|
||||
.where(p => p.score.gt(1000))
|
||||
(ctx) => ctx.from.player.where((p) => p.score.gt(1000)),
|
||||
);
|
||||
```
|
||||
|
||||
### ViewContext vs AnonymousViewContext
|
||||
|
||||
```typescript
|
||||
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
|
||||
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
|
||||
return [...ctx.db.item.by_owner.filter(ctx.sender)];
|
||||
});
|
||||
spacetimedb.view(
|
||||
{ name: "my_items", public: true },
|
||||
t.array(Item.rowType),
|
||||
(ctx) => {
|
||||
return [...ctx.db.item.by_owner.filter(ctx.sender)];
|
||||
},
|
||||
);
|
||||
|
||||
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
|
||||
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
|
||||
return [...ctx.db.player.by_score.filter(/* top scores */)];
|
||||
});
|
||||
spacetimedb.anonymousView(
|
||||
{ name: "leaderboard", public: true },
|
||||
t.array(LeaderboardRow),
|
||||
(ctx) => {
|
||||
return [...ctx.db.player.by_score.filter(/* top scores */)];
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Views require explicit subscription:**
|
||||
|
||||
```typescript
|
||||
conn.subscriptionBuilder().subscribe([
|
||||
'SELECT * FROM public_table',
|
||||
'SELECT * FROM my_data', // Views need explicit SQL!
|
||||
"SELECT * FROM public_table",
|
||||
"SELECT * FROM my_data", // Views need explicit SQL!
|
||||
]);
|
||||
```
|
||||
|
||||
@@ -622,16 +689,18 @@ conn.subscriptionBuilder().subscribe([
|
||||
## 8) React Integration
|
||||
|
||||
### Key patterns
|
||||
|
||||
```typescript
|
||||
// Memoize connectionBuilder to prevent reconnects on re-render
|
||||
const builder = useMemo(() =>
|
||||
DbConnection.builder()
|
||||
.withUri(SPACETIMEDB_URI)
|
||||
.withDatabaseName(MODULE_NAME)
|
||||
.withToken(localStorage.getItem('auth_token') || undefined)
|
||||
.onConnect(onConnect)
|
||||
.onConnectError(onConnectError),
|
||||
[] // Empty deps - only create once
|
||||
const builder = useMemo(
|
||||
() =>
|
||||
DbConnection.builder()
|
||||
.withUri(SPACETIMEDB_URI)
|
||||
.withDatabaseName(MODULE_NAME)
|
||||
.withToken(localStorage.getItem("auth_token") || undefined)
|
||||
.onConnect(onConnect)
|
||||
.onConnectError(onConnectError),
|
||||
[], // Empty deps - only create once
|
||||
);
|
||||
|
||||
// useTable returns tuple [rows, isLoading]
|
||||
@@ -650,17 +719,18 @@ const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();
|
||||
⚠️ Procedures are currently in beta. API may change.
|
||||
|
||||
### Defining a procedure
|
||||
|
||||
**Procedure name comes from the export — NOT from a string argument.** Use `procedure(params, ret, fn)` or `procedure(ret, fn)`.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT — export const name = spacetimedb.procedure(params, ret, fn)
|
||||
export const fetch_external_data = spacetimedb.procedure(
|
||||
{ url: t.string() },
|
||||
t.string(), // return type
|
||||
t.string(), // return type
|
||||
(ctx, { url }) => {
|
||||
const response = ctx.http.fetch(url);
|
||||
return response.text();
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
@@ -692,18 +762,20 @@ spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
|
||||
```
|
||||
|
||||
### Key differences from reducers
|
||||
| Reducers | Procedures |
|
||||
|----------|------------|
|
||||
|
||||
| Reducers | Procedures |
|
||||
| --------------------------- | ------------------------------------- |
|
||||
| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` |
|
||||
| Automatic transaction | Manual transaction management |
|
||||
| No HTTP/network | `ctx.http.fetch()` available |
|
||||
| No return values to caller | Can return data to caller |
|
||||
| Automatic transaction | Manual transaction management |
|
||||
| No HTTP/network | `ctx.http.fetch()` available |
|
||||
| No return values to caller | Can return data to caller |
|
||||
|
||||
---
|
||||
|
||||
## 10) Project Structure
|
||||
|
||||
### Server (`backend/spacetimedb/`)
|
||||
|
||||
```
|
||||
src/schema.ts → Tables, export spacetimedb
|
||||
src/index.ts → Reducers, lifecycle, import schema
|
||||
@@ -712,12 +784,14 @@ tsconfig.json → Standard config
|
||||
```
|
||||
|
||||
### Avoiding circular imports
|
||||
|
||||
```
|
||||
schema.ts → defines tables AND exports spacetimedb
|
||||
index.ts → imports spacetimedb from ./schema, defines reducers
|
||||
```
|
||||
|
||||
### Client (`client/`)
|
||||
|
||||
```
|
||||
src/module_bindings/ → Generated (spacetime generate)
|
||||
src/main.tsx → Provider, connection setup
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
## Language-Specific Rules
|
||||
|
||||
| Language | Rule File |
|
||||
|----------|-----------|
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` |
|
||||
| Language | Rule File |
|
||||
| ----------------------- | ---------------------------------------- |
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **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
|
||||
|
||||
SpacetimeDB automatically creates indexes for:
|
||||
|
||||
- Primary key columns
|
||||
- Columns marked as unique
|
||||
|
||||
@@ -52,6 +53,7 @@ You can add explicit indexes on non-unique columns for query performance.
|
||||
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
|
||||
|
||||
**Schema ↔ Code coupling:**
|
||||
|
||||
- Your query code references indexes by name
|
||||
- If you add/remove/rename an index in the schema, update all code that uses it
|
||||
- Removing an index without updating queries causes runtime errors
|
||||
@@ -85,7 +87,7 @@ spacetime logs <db-name>
|
||||
## Deployment
|
||||
|
||||
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
|
||||
- The default server marked by *** in `spacetime server list` should be used when publishing
|
||||
- The default server marked by \*\*\* in `spacetime server list` should be used when publishing
|
||||
- If the default server is maincloud you should publish to maincloud
|
||||
- Publishing to maincloud is free of charge
|
||||
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
|
||||
@@ -110,7 +112,6 @@ spacetime logs <db-name>
|
||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
|
||||
|
||||
|
||||
# SpacetimeDB TypeScript SDK
|
||||
|
||||
## ⛔ HALLUCINATED APIs — DO NOT USE
|
||||
@@ -139,11 +140,11 @@ tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object!
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT IMPORTS
|
||||
import { DbConnection, tables } from './module_bindings'; // Generated!
|
||||
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
|
||||
import { DbConnection, tables } from "./module_bindings"; // Generated!
|
||||
import { SpacetimeDBProvider, useTable, Identity } from "spacetimedb/react";
|
||||
|
||||
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
|
||||
conn.reducers.doSomething({ value: 'test' });
|
||||
conn.reducers.doSomething({ value: "test" });
|
||||
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
|
||||
|
||||
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
|
||||
@@ -151,6 +152,7 @@ const [items, isLoading] = useTable(tables.item);
|
||||
```
|
||||
|
||||
### ⛔ DO NOT:
|
||||
|
||||
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
|
||||
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
|
||||
|
||||
@@ -160,40 +162,40 @@ const [items, isLoading] = useTable(tables.item);
|
||||
|
||||
### Server-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| Missing `package.json` | Create `package.json` | "could not detect language" |
|
||||
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
|
||||
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
|
||||
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error |
|
||||
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
|
||||
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
|
||||
| `.filter()` on unique column | `.find()` on unique column | TypeError |
|
||||
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
|
||||
| `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" |
|
||||
| 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" |
|
||||
| `.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" |
|
||||
| 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" |
|
||||
| `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" |
|
||||
| `.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.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable |
|
||||
| Wrong | Right | Error |
|
||||
| ------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Missing `package.json` | Create `package.json` | "could not detect language" |
|
||||
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
|
||||
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
|
||||
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error |
|
||||
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
|
||||
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
|
||||
| `.filter()` on unique column | `.find()` on unique column | TypeError |
|
||||
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
|
||||
| `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" |
|
||||
| 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" |
|
||||
| `.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" |
|
||||
| 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" |
|
||||
| `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" |
|
||||
| `.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.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable |
|
||||
|
||||
### Client-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
|
||||
| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
|
||||
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
|
||||
| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring |
|
||||
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
|
||||
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
|
||||
| Wrong | Right | Error |
|
||||
| ------------------------------------- | ------------------------------------------- | ----------------------- |
|
||||
| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
|
||||
| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
|
||||
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
|
||||
| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring |
|
||||
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
|
||||
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
|
||||
|
||||
---
|
||||
|
||||
@@ -202,62 +204,77 @@ const [items, isLoading] = useTable(tables.item);
|
||||
**`table()` takes TWO arguments: `table(OPTIONS, COLUMNS)`**
|
||||
|
||||
```typescript
|
||||
import { schema, table, t } from 'spacetimedb/server';
|
||||
import { schema, table, t } from "spacetimedb/server";
|
||||
|
||||
// ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error
|
||||
export const Task = table({ name: 'task' }, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] // ❌ WRONG!
|
||||
});
|
||||
export const Task = table(
|
||||
{ name: "task" },
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }], // ❌ WRONG!
|
||||
},
|
||||
);
|
||||
|
||||
// ✅ RIGHT — indexes in OPTIONS (first argument)
|
||||
export const Task = table({
|
||||
name: 'task',
|
||||
public: true,
|
||||
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
|
||||
}, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
title: t.string(),
|
||||
createdAt: t.timestamp(),
|
||||
});
|
||||
export const Task = table(
|
||||
{
|
||||
name: "task",
|
||||
public: true,
|
||||
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
title: t.string(),
|
||||
createdAt: t.timestamp(),
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Column types
|
||||
|
||||
```typescript
|
||||
t.identity() // User identity (primary key for per-user tables)
|
||||
t.u64() // Unsigned 64-bit integer (use for IDs)
|
||||
t.string() // Text
|
||||
t.bool() // Boolean
|
||||
t.timestamp() // Timestamp (use ctx.timestamp for current time)
|
||||
t.scheduleAt() // For scheduled tables only
|
||||
t.identity(); // User identity (primary key for per-user tables)
|
||||
t.u64(); // Unsigned 64-bit integer (use for IDs)
|
||||
t.string(); // Text
|
||||
t.bool(); // Boolean
|
||||
t.timestamp(); // Timestamp (use ctx.timestamp for current time)
|
||||
t.scheduleAt(); // For scheduled tables only
|
||||
|
||||
// Product types (nested objects) — use t.object, NOT t.struct
|
||||
const Point = t.object('Point', { x: t.i32(), y: t.i32() });
|
||||
const Point = t.object("Point", { x: t.i32(), y: t.i32() });
|
||||
|
||||
// Sum types (tagged unions) — use t.enum, NOT t.sum
|
||||
const Shape = t.enum('Shape', { circle: t.i32(), rectangle: Point });
|
||||
const Shape = t.enum("Shape", { circle: t.i32(), rectangle: Point });
|
||||
// Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } }
|
||||
|
||||
// Modifiers
|
||||
t.string().optional() // Nullable
|
||||
t.u64().primaryKey() // Primary key
|
||||
t.u64().primaryKey().autoInc() // Auto-increment primary key
|
||||
t.string().optional(); // Nullable
|
||||
t.u64().primaryKey(); // Primary key
|
||||
t.u64().primaryKey().autoInc(); // Auto-increment primary key
|
||||
```
|
||||
|
||||
> ⚠️ **BIGINT SYNTAX:** All `u64`, `i64`, and ID fields use JavaScript BigInt.
|
||||
>
|
||||
> - Literals: `0n`, `1n`, `100n` (NOT `0`, `1`, `100`)
|
||||
> - Comparisons: `row.id === 5n` (NOT `row.id === 5`)
|
||||
> - Arithmetic: `row.count + 1n` (NOT `row.count + 1`)
|
||||
|
||||
### Auto-increment placeholder
|
||||
|
||||
```typescript
|
||||
// ✅ MUST provide 0n placeholder for auto-inc fields
|
||||
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title: 'New', createdAt: ctx.timestamp });
|
||||
ctx.db.task.insert({
|
||||
id: 0n,
|
||||
ownerId: ctx.sender,
|
||||
title: "New",
|
||||
createdAt: ctx.timestamp,
|
||||
});
|
||||
```
|
||||
|
||||
### Insert returns ROW, not ID
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
const id = ctx.db.task.insert({ ... });
|
||||
@@ -268,14 +285,15 @@ const newId = row.id; // Extract .id from returned row
|
||||
```
|
||||
|
||||
### Schema export (CRITICAL)
|
||||
|
||||
```typescript
|
||||
// At end of schema.ts — schema() takes exactly ONE argument: an object
|
||||
const spacetimedb = schema({ table1, table2, table3 });
|
||||
export default spacetimedb;
|
||||
|
||||
// ❌ WRONG — never pass tables directly or as multiple args
|
||||
schema(myTable); // WRONG!
|
||||
schema(t1, t2, t3); // WRONG!
|
||||
schema(myTable); // 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
|
||||
for (const m of ctx.db.roomMember.iter()) {
|
||||
if (m.roomId === roomId) { /* ... */ }
|
||||
if (m.roomId === roomId) {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -302,24 +322,31 @@ for (const m of ctx.db.roomMember.iter()) {
|
||||
|
||||
```typescript
|
||||
// In table OPTIONS (first argument), not columns
|
||||
export const Message = table({
|
||||
name: 'message',
|
||||
public: true,
|
||||
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
|
||||
}, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
roomId: t.u64(),
|
||||
// ...
|
||||
});
|
||||
export const Message = table(
|
||||
{
|
||||
name: "message",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ name: "message_room_id", algorithm: "btree", columns: ["roomId"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
roomId: t.u64(),
|
||||
// ...
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Naming conventions
|
||||
|
||||
**Table names — automatic transformation:**
|
||||
|
||||
- Schema: `table({ name: 'my_messages' })`
|
||||
- Access: `ctx.db.myMessages` (automatic snake_case → camelCase)
|
||||
|
||||
**Index names — NO transformation, use EXACTLY as defined:**
|
||||
|
||||
```typescript
|
||||
// Schema definition
|
||||
indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }]
|
||||
@@ -335,6 +362,7 @@ ctx.db.canvasMember.canvas_member_canvas_id.filter(...)
|
||||
> ⚠️ **Index names are used VERBATIM** — pick a convention (snake_case or camelCase) and stick with it.
|
||||
|
||||
**Index naming pattern — use `{tableName}_{columnName}`:**
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD — unique names across entire module
|
||||
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
|
||||
@@ -346,10 +374,12 @@ indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT!
|
||||
```
|
||||
|
||||
**Client-side table names:**
|
||||
|
||||
- Check generated `module_bindings/index.ts` for exact export names
|
||||
- Usage: `useTable(tables.MyMessages)` or `tables.myMessages` (varies by SDK version)
|
||||
|
||||
### Filter vs Find
|
||||
|
||||
```typescript
|
||||
// Filter takes VALUE directly, not object — returns iterator
|
||||
const rows = [...ctx.db.task.by_owner.filter(ownerId)];
|
||||
@@ -359,13 +389,16 @@ const row = ctx.db.player.identity.find(ctx.sender);
|
||||
```
|
||||
|
||||
### ⚠️ Multi-column indexes are BROKEN
|
||||
|
||||
```typescript
|
||||
// ❌ DON'T — causes PANIC
|
||||
ctx.db.scores.by_player_level.filter(playerId);
|
||||
|
||||
// ✅ DO — use single-column index + manual filter
|
||||
for (const row of ctx.db.scores.by_player.filter(playerId)) {
|
||||
if (row.level === targetLevel) { /* ... */ }
|
||||
if (row.level === targetLevel) {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -374,6 +407,7 @@ for (const row of ctx.db.scores.by_player.filter(playerId)) {
|
||||
## 4) Reducers
|
||||
|
||||
### Definition syntax (CRITICAL)
|
||||
|
||||
**Reducer name comes from the export — NOT from a string argument.** Use `reducer(params, fn)` or `reducer(fn)`.
|
||||
|
||||
```typescript
|
||||
@@ -403,24 +437,31 @@ spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) =>
|
||||
```
|
||||
|
||||
### Update pattern (CRITICAL)
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT — spread existing row, override specific fields
|
||||
const existing = ctx.db.task.id.find(taskId);
|
||||
if (!existing) throw new SenderError('Task not found');
|
||||
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
|
||||
if (!existing) throw new SenderError("Task not found");
|
||||
ctx.db.task.id.update({
|
||||
...existing,
|
||||
title: newTitle,
|
||||
updatedAt: ctx.timestamp,
|
||||
});
|
||||
|
||||
// ❌ WRONG — partial update nulls out other fields!
|
||||
ctx.db.task.id.update({ id: taskId, title: newTitle });
|
||||
```
|
||||
|
||||
### Delete pattern
|
||||
|
||||
```typescript
|
||||
// Delete by primary key VALUE (not row object)
|
||||
ctx.db.task.id.delete(taskId); // taskId is the u64 value
|
||||
ctx.db.player.identity.delete(ctx.sender); // delete by identity
|
||||
ctx.db.task.id.delete(taskId); // taskId is the u64 value
|
||||
ctx.db.player.identity.delete(ctx.sender); // delete by identity
|
||||
```
|
||||
|
||||
### Lifecycle hooks
|
||||
|
||||
```typescript
|
||||
spacetimedb.clientConnected((ctx) => {
|
||||
// ctx.sender is the connecting identity
|
||||
@@ -433,16 +474,18 @@ spacetimedb.clientDisconnected((ctx) => {
|
||||
```
|
||||
|
||||
### Snake_case to camelCase conversion
|
||||
|
||||
- Server: `export const do_something = spacetimedb.reducer(...)` — name from export
|
||||
- Client: `conn.reducers.doSomething({ ... })`
|
||||
|
||||
### Object syntax required
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - positional
|
||||
conn.reducers.doSomething('value');
|
||||
conn.reducers.doSomething("value");
|
||||
|
||||
// ✅ RIGHT - object
|
||||
conn.reducers.doSomething({ param: 'value' });
|
||||
conn.reducers.doSomething({ param: "value" });
|
||||
```
|
||||
|
||||
---
|
||||
@@ -451,28 +494,34 @@ conn.reducers.doSomething({ param: 'value' });
|
||||
|
||||
```typescript
|
||||
// 1. Define table first (scheduled: () => reducer — pass the exported reducer)
|
||||
export const CleanupJob = table({
|
||||
name: 'cleanup_job',
|
||||
scheduled: () => run_cleanup // reducer defined below
|
||||
}, {
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
targetId: t.u64(), // Your custom data
|
||||
});
|
||||
export const CleanupJob = table(
|
||||
{
|
||||
name: "cleanup_job",
|
||||
scheduled: () => run_cleanup, // reducer defined below
|
||||
},
|
||||
{
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
targetId: t.u64(), // Your custom data
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Define scheduled reducer (receives full row as arg)
|
||||
export const run_cleanup = spacetimedb.reducer({ arg: CleanupJob.rowType }, (ctx, { arg }) => {
|
||||
// arg.scheduledId, arg.targetId available
|
||||
// Row is auto-deleted after reducer completes
|
||||
});
|
||||
export const run_cleanup = spacetimedb.reducer(
|
||||
{ arg: CleanupJob.rowType },
|
||||
(ctx, { arg }) => {
|
||||
// arg.scheduledId, arg.targetId available
|
||||
// Row is auto-deleted after reducer completes
|
||||
},
|
||||
);
|
||||
|
||||
// Schedule a job
|
||||
import { ScheduleAt } from 'spacetimedb';
|
||||
import { ScheduleAt } from "spacetimedb";
|
||||
const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
|
||||
ctx.db.cleanupJob.insert({
|
||||
scheduledId: 0n,
|
||||
scheduledAt: ScheduleAt.time(futureTime),
|
||||
targetId: someId
|
||||
targetId: someId,
|
||||
});
|
||||
|
||||
// Cancel a job by deleting the row
|
||||
@@ -484,18 +533,21 @@ ctx.db.cleanupJob.scheduledId.delete(jobId);
|
||||
## 6) Timestamps
|
||||
|
||||
### Server-side
|
||||
|
||||
```typescript
|
||||
import { Timestamp, ScheduleAt } from 'spacetimedb';
|
||||
import { Timestamp, ScheduleAt } from "spacetimedb";
|
||||
|
||||
// Current time
|
||||
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
|
||||
|
||||
// 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)
|
||||
|
||||
**Timestamps are objects, not numbers:**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
const date = new Date(row.createdAt);
|
||||
@@ -506,9 +558,10 @@ const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
|
||||
```
|
||||
|
||||
### ScheduleAt on client
|
||||
|
||||
```typescript
|
||||
// ScheduleAt is a tagged union
|
||||
if (scheduleAt.tag === 'Time') {
|
||||
if (scheduleAt.tag === "Time") {
|
||||
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
|
||||
}
|
||||
```
|
||||
@@ -519,32 +572,37 @@ if (scheduleAt.tag === 'Time') {
|
||||
|
||||
**`public: true` exposes ALL rows to ALL clients.**
|
||||
|
||||
| Scenario | Pattern |
|
||||
|----------|---------|
|
||||
| Everyone sees all rows | `public: true` |
|
||||
| Scenario | Pattern |
|
||||
| ------------------------- | ------------------------------------- |
|
||||
| Everyone sees all rows | `public: true` |
|
||||
| Users see only their data | Private table + filtered subscription |
|
||||
|
||||
### Subscription patterns (client-side)
|
||||
|
||||
```typescript
|
||||
// Subscribe to ALL public tables (simplest)
|
||||
conn.subscriptionBuilder().subscribeToAll();
|
||||
|
||||
// Subscribe to specific tables with SQL
|
||||
conn.subscriptionBuilder().subscribe([
|
||||
'SELECT * FROM message',
|
||||
'SELECT * FROM room WHERE is_public = true',
|
||||
]);
|
||||
conn
|
||||
.subscriptionBuilder()
|
||||
.subscribe([
|
||||
"SELECT * FROM message",
|
||||
"SELECT * FROM room WHERE is_public = true",
|
||||
]);
|
||||
|
||||
// Handle subscription lifecycle
|
||||
conn.subscriptionBuilder()
|
||||
.onApplied(() => console.log('Initial data loaded'))
|
||||
.onError((e) => console.error('Subscription failed:', e))
|
||||
conn
|
||||
.subscriptionBuilder()
|
||||
.onApplied(() => console.log("Initial data loaded"))
|
||||
.onError((e) => console.error("Subscription failed:", e))
|
||||
.subscribeToAll();
|
||||
```
|
||||
|
||||
### Private table + view pattern (RECOMMENDED)
|
||||
|
||||
**Views are the recommended approach** for controlling data visibility. They provide:
|
||||
|
||||
- Server-side filtering (reduces network traffic)
|
||||
- Real-time updates when underlying data changes
|
||||
- Full control over what data clients can access
|
||||
@@ -557,28 +615,29 @@ conn.subscriptionBuilder()
|
||||
```typescript
|
||||
// Private table with index on ownerId
|
||||
export const PrivateData = table(
|
||||
{ name: 'private_data',
|
||||
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
|
||||
{
|
||||
name: "private_data",
|
||||
indexes: [{ name: "by_owner", algorithm: "btree", columns: ["ownerId"] }],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
secret: t.string()
|
||||
}
|
||||
secret: t.string(),
|
||||
},
|
||||
);
|
||||
|
||||
// ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change)
|
||||
spacetimedb.view(
|
||||
{ name: 'my_data_slow', public: true },
|
||||
{ name: "my_data_slow", public: true },
|
||||
t.array(PrivateData.rowType),
|
||||
(ctx) => [...ctx.db.privateData.iter()] // Works but VERY slow at scale
|
||||
(ctx) => [...ctx.db.privateData.iter()], // Works but VERY slow at scale
|
||||
);
|
||||
|
||||
// ✅ GOOD — index lookup enables targeted invalidation
|
||||
spacetimedb.view(
|
||||
{ name: 'my_data', public: true },
|
||||
{ name: "my_data", public: true },
|
||||
t.array(PrivateData.rowType),
|
||||
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)]
|
||||
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)],
|
||||
);
|
||||
```
|
||||
|
||||
@@ -588,32 +647,40 @@ spacetimedb.view(
|
||||
// Query-builder views return a query; the SQL engine maintains the result incrementally.
|
||||
// This can scan the whole table if needed (e.g. leaderboard-style queries).
|
||||
spacetimedb.anonymousView(
|
||||
{ name: 'top_players', public: true },
|
||||
{ name: "top_players", public: true },
|
||||
t.array(Player.rowType),
|
||||
(ctx) =>
|
||||
ctx.from.player
|
||||
.where(p => p.score.gt(1000))
|
||||
(ctx) => ctx.from.player.where((p) => p.score.gt(1000)),
|
||||
);
|
||||
```
|
||||
|
||||
### ViewContext vs AnonymousViewContext
|
||||
|
||||
```typescript
|
||||
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
|
||||
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
|
||||
return [...ctx.db.item.by_owner.filter(ctx.sender)];
|
||||
});
|
||||
spacetimedb.view(
|
||||
{ name: "my_items", public: true },
|
||||
t.array(Item.rowType),
|
||||
(ctx) => {
|
||||
return [...ctx.db.item.by_owner.filter(ctx.sender)];
|
||||
},
|
||||
);
|
||||
|
||||
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
|
||||
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
|
||||
return [...ctx.db.player.by_score.filter(/* top scores */)];
|
||||
});
|
||||
spacetimedb.anonymousView(
|
||||
{ name: "leaderboard", public: true },
|
||||
t.array(LeaderboardRow),
|
||||
(ctx) => {
|
||||
return [...ctx.db.player.by_score.filter(/* top scores */)];
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Views require explicit subscription:**
|
||||
|
||||
```typescript
|
||||
conn.subscriptionBuilder().subscribe([
|
||||
'SELECT * FROM public_table',
|
||||
'SELECT * FROM my_data', // Views need explicit SQL!
|
||||
"SELECT * FROM public_table",
|
||||
"SELECT * FROM my_data", // Views need explicit SQL!
|
||||
]);
|
||||
```
|
||||
|
||||
@@ -622,16 +689,18 @@ conn.subscriptionBuilder().subscribe([
|
||||
## 8) React Integration
|
||||
|
||||
### Key patterns
|
||||
|
||||
```typescript
|
||||
// Memoize connectionBuilder to prevent reconnects on re-render
|
||||
const builder = useMemo(() =>
|
||||
DbConnection.builder()
|
||||
.withUri(SPACETIMEDB_URI)
|
||||
.withDatabaseName(MODULE_NAME)
|
||||
.withToken(localStorage.getItem('auth_token') || undefined)
|
||||
.onConnect(onConnect)
|
||||
.onConnectError(onConnectError),
|
||||
[] // Empty deps - only create once
|
||||
const builder = useMemo(
|
||||
() =>
|
||||
DbConnection.builder()
|
||||
.withUri(SPACETIMEDB_URI)
|
||||
.withDatabaseName(MODULE_NAME)
|
||||
.withToken(localStorage.getItem("auth_token") || undefined)
|
||||
.onConnect(onConnect)
|
||||
.onConnectError(onConnectError),
|
||||
[], // Empty deps - only create once
|
||||
);
|
||||
|
||||
// useTable returns tuple [rows, isLoading]
|
||||
@@ -650,17 +719,18 @@ const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();
|
||||
⚠️ Procedures are currently in beta. API may change.
|
||||
|
||||
### Defining a procedure
|
||||
|
||||
**Procedure name comes from the export — NOT from a string argument.** Use `procedure(params, ret, fn)` or `procedure(ret, fn)`.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT — export const name = spacetimedb.procedure(params, ret, fn)
|
||||
export const fetch_external_data = spacetimedb.procedure(
|
||||
{ url: t.string() },
|
||||
t.string(), // return type
|
||||
t.string(), // return type
|
||||
(ctx, { url }) => {
|
||||
const response = ctx.http.fetch(url);
|
||||
return response.text();
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
@@ -692,18 +762,20 @@ spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
|
||||
```
|
||||
|
||||
### Key differences from reducers
|
||||
| Reducers | Procedures |
|
||||
|----------|------------|
|
||||
|
||||
| Reducers | Procedures |
|
||||
| --------------------------- | ------------------------------------- |
|
||||
| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` |
|
||||
| Automatic transaction | Manual transaction management |
|
||||
| No HTTP/network | `ctx.http.fetch()` available |
|
||||
| No return values to caller | Can return data to caller |
|
||||
| Automatic transaction | Manual transaction management |
|
||||
| No HTTP/network | `ctx.http.fetch()` available |
|
||||
| No return values to caller | Can return data to caller |
|
||||
|
||||
---
|
||||
|
||||
## 10) Project Structure
|
||||
|
||||
### Server (`backend/spacetimedb/`)
|
||||
|
||||
```
|
||||
src/schema.ts → Tables, export spacetimedb
|
||||
src/index.ts → Reducers, lifecycle, import schema
|
||||
@@ -712,12 +784,14 @@ tsconfig.json → Standard config
|
||||
```
|
||||
|
||||
### Avoiding circular imports
|
||||
|
||||
```
|
||||
schema.ts → defines tables AND exports spacetimedb
|
||||
index.ts → imports spacetimedb from ./schema, defines reducers
|
||||
```
|
||||
|
||||
### Client (`client/`)
|
||||
|
||||
```
|
||||
src/module_bindings/ → Generated (spacetime generate)
|
||||
src/main.tsx → Provider, connection setup
|
||||
|
||||
@@ -35,7 +35,7 @@ export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
@@ -48,11 +48,11 @@ export default tseslint.config({
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react';
|
||||
import react from "eslint-plugin-react";
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
settings: { react: { version: "18.3" } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
@@ -61,7 +61,7 @@ export default tseslint.config({
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...react.configs["jsx-runtime"].rules,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Vendored
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<script type="module" crossorigin src="/assets/index-BTS-ufhw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DbhXHqjh.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DbhXHqjh.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+2598
-1281
File diff suppressed because it is too large
Load Diff
Generated
+50
-23
@@ -1,11 +1,10 @@
|
||||
lockfileVersion: '9.0'
|
||||
lockfileVersion: "9.0"
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
spacetimedb:
|
||||
@@ -17,42 +16,62 @@ importers:
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==,
|
||||
}
|
||||
|
||||
headers-polyfill@4.0.3:
|
||||
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==,
|
||||
}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==,
|
||||
}
|
||||
engines: { node: ">= 0.4" }
|
||||
|
||||
prettier@3.8.1:
|
||||
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
|
||||
engines: {node: '>=14'}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==,
|
||||
}
|
||||
engines: { node: ">=14" }
|
||||
hasBin: true
|
||||
|
||||
pure-rand@7.0.1:
|
||||
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==,
|
||||
}
|
||||
|
||||
safe-stable-stringify@2.5.0:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==,
|
||||
}
|
||||
engines: { node: ">=10" }
|
||||
|
||||
spacetimedb@2.1.0:
|
||||
resolution: {integrity: sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==,
|
||||
}
|
||||
peerDependencies:
|
||||
'@angular/core': '>=17.0.0'
|
||||
'@tanstack/react-query': ^5.0.0
|
||||
"@angular/core": ">=17.0.0"
|
||||
"@tanstack/react-query": ^5.0.0
|
||||
react: ^18.0.0 || ^19.0.0-0 || ^19.0.0
|
||||
svelte: ^4.0.0 || ^5.0.0
|
||||
undici: ^6.19.2
|
||||
vue: ^3.3.0
|
||||
peerDependenciesMeta:
|
||||
'@angular/core':
|
||||
"@angular/core":
|
||||
optional: true
|
||||
'@tanstack/react-query':
|
||||
"@tanstack/react-query":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
@@ -64,19 +83,27 @@ packages:
|
||||
optional: true
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==,
|
||||
}
|
||||
engines: { node: ">= 0.8" }
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==,
|
||||
}
|
||||
engines: { node: ">=14.17" }
|
||||
hasBin: true
|
||||
|
||||
url-polyfill@1.1.14:
|
||||
resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==}
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==,
|
||||
}
|
||||
|
||||
snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
headers-polyfill@4.0.3: {}
|
||||
|
||||
+418
-165
@@ -1,10 +1,10 @@
|
||||
import { schema, t, table, SenderError } from 'spacetimedb/server';
|
||||
import { schema, t, table, SenderError } from "spacetimedb/server";
|
||||
|
||||
const channel_kind = t.enum('ChannelKind', { Text: t.unit(), Voice: t.unit() });
|
||||
const channel_kind = t.enum("ChannelKind", { Text: t.unit(), Voice: t.unit() });
|
||||
|
||||
const user = table(
|
||||
{
|
||||
name: 'user',
|
||||
name: "user",
|
||||
public: true,
|
||||
},
|
||||
{
|
||||
@@ -16,90 +16,95 @@ const user = table(
|
||||
subject: t.string().optional(),
|
||||
username: t.string().optional(), // For creds-based auth
|
||||
password: t.string().optional(), // For creds-based auth (Note: plain text for MVP)
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const server = table(
|
||||
{ name: 'server', public: true },
|
||||
{ name: "server", public: true },
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
name: t.string(),
|
||||
owner: t.identity().optional(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const server_member = table(
|
||||
{
|
||||
name: 'server_member',
|
||||
name: "server_member",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: 'by_identity', algorithm: 'btree', columns: ['identity'] },
|
||||
{ accessor: 'by_server_id', algorithm: 'btree', columns: ['server_id'] }
|
||||
]
|
||||
{ accessor: "by_identity", algorithm: "btree", columns: ["identity"] },
|
||||
{ accessor: "by_server_id", algorithm: "btree", columns: ["server_id"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
identity: t.identity(),
|
||||
server_id: t.u64(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const channel = table(
|
||||
{
|
||||
name: 'channel',
|
||||
name: "channel",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: 'by_server_id', algorithm: 'btree', columns: ['server_id'] }
|
||||
]
|
||||
{ accessor: "by_server_id", algorithm: "btree", columns: ["server_id"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
server_id: t.u64(),
|
||||
name: t.string(),
|
||||
kind: channel_kind,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const voice_state = table(
|
||||
{
|
||||
name: 'voice_state',
|
||||
name: "voice_state",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
|
||||
]
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
identity: t.identity().primaryKey(),
|
||||
channel_id: t.u64(),
|
||||
is_sharing_screen: t.bool(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const watching = table(
|
||||
{
|
||||
name: 'watching',
|
||||
name: "watching",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: 'by_watcher', algorithm: 'btree', columns: ['watcher'] },
|
||||
{ accessor: 'by_watchee', algorithm: 'btree', columns: ['watchee'] }
|
||||
]
|
||||
{ accessor: "by_watcher", algorithm: "btree", columns: ["watcher"] },
|
||||
{ accessor: "by_watchee", algorithm: "btree", columns: ["watchee"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
watcher: t.identity(),
|
||||
watchee: t.identity(),
|
||||
channel_id: t.u64(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const sdp_offer = table(
|
||||
// --- Voice Signaling Tables ---
|
||||
const voice_sdp_offer = table(
|
||||
{
|
||||
name: 'sdp_offer',
|
||||
name: "voice_sdp_offer",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] },
|
||||
{ accessor: 'by_sender', algorithm: 'btree', columns: ['sender'] }
|
||||
]
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
@@ -107,17 +112,17 @@ const sdp_offer = table(
|
||||
receiver: t.identity(),
|
||||
sdp: t.string(),
|
||||
channel_id: t.u64(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const sdp_answer = table(
|
||||
const voice_sdp_answer = table(
|
||||
{
|
||||
name: 'sdp_answer',
|
||||
name: "voice_sdp_answer",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] },
|
||||
{ accessor: 'by_sender', algorithm: 'btree', columns: ['sender'] }
|
||||
]
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
@@ -125,17 +130,17 @@ const sdp_answer = table(
|
||||
receiver: t.identity(),
|
||||
sdp: t.string(),
|
||||
channel_id: t.u64(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const ice_candidate = table(
|
||||
const voice_ice_candidate = table(
|
||||
{
|
||||
name: 'ice_candidate',
|
||||
name: "voice_ice_candidate",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] },
|
||||
{ accessor: 'by_sender', algorithm: 'btree', columns: ['sender'] }
|
||||
]
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
@@ -143,33 +148,96 @@ const ice_candidate = table(
|
||||
receiver: t.identity(),
|
||||
candidate: t.string(),
|
||||
channel_id: t.u64(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// --- Screen Signaling Tables ---
|
||||
const screen_sdp_offer = table(
|
||||
{
|
||||
name: "screen_sdp_offer",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
receiver: t.identity(),
|
||||
sdp: t.string(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
const screen_sdp_answer = table(
|
||||
{
|
||||
name: "screen_sdp_answer",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
receiver: t.identity(),
|
||||
sdp: t.string(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
const screen_ice_candidate = table(
|
||||
{
|
||||
name: "screen_ice_candidate",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
|
||||
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
sender: t.identity(),
|
||||
receiver: t.identity(),
|
||||
candidate: t.string(),
|
||||
channel_id: t.u64(),
|
||||
},
|
||||
);
|
||||
|
||||
const thread = table(
|
||||
{
|
||||
name: 'thread',
|
||||
name: "thread",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
|
||||
]
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
channel_id: t.u64(),
|
||||
parent_message_id: t.u64().unique(),
|
||||
name: t.string(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const message = table(
|
||||
{
|
||||
name: 'message',
|
||||
name: "message",
|
||||
public: true,
|
||||
indexes: [
|
||||
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] },
|
||||
{ accessor: 'by_thread_id', algorithm: 'btree', columns: ['thread_id'] }
|
||||
]
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
algorithm: "btree",
|
||||
columns: ["channel_id"],
|
||||
},
|
||||
{ accessor: "by_thread_id", algorithm: "btree", columns: ["thread_id"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
@@ -178,14 +246,30 @@ const message = table(
|
||||
text: t.string(),
|
||||
channel_id: t.u64(),
|
||||
thread_id: t.u64().optional(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const spacetimedb = schema({ user, server, server_member, channel, voice_state, watching, sdp_offer, sdp_answer, ice_candidate, thread, message });
|
||||
const spacetimedb = schema({
|
||||
user,
|
||||
server,
|
||||
server_member,
|
||||
channel,
|
||||
voice_state,
|
||||
watching,
|
||||
voice_sdp_offer,
|
||||
voice_sdp_answer,
|
||||
voice_ice_candidate,
|
||||
screen_sdp_offer,
|
||||
screen_sdp_answer,
|
||||
screen_ice_candidate,
|
||||
thread,
|
||||
message,
|
||||
});
|
||||
export default spacetimedb;
|
||||
|
||||
function validateName(name: string) {
|
||||
if (!name || name.trim().length === 0) throw new SenderError('Names must not be empty');
|
||||
if (!name || name.trim().length === 0)
|
||||
throw new SenderError("Names must not be empty");
|
||||
}
|
||||
|
||||
export const set_name = spacetimedb.reducer(
|
||||
@@ -193,29 +277,31 @@ export const set_name = spacetimedb.reducer(
|
||||
(ctx, { name }) => {
|
||||
validateName(name);
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) throw new SenderError('Cannot set name for unknown user');
|
||||
if (!user) throw new SenderError("Cannot set name for unknown user");
|
||||
ctx.db.user.identity.update({ ...user, name });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const set_talking = spacetimedb.reducer(
|
||||
{ talking: t.bool() },
|
||||
(ctx, { talking }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user) throw new SenderError('Cannot set talking status for unknown user');
|
||||
if (!user)
|
||||
throw new SenderError("Cannot set talking status for unknown user");
|
||||
ctx.db.user.identity.update({ ...user, talking });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const register = spacetimedb.reducer(
|
||||
{ username: t.string(), password: t.string() },
|
||||
(ctx, { username, password }) => {
|
||||
validateName(username);
|
||||
if (!password || password.length < 4) throw new SenderError('Password must be at least 4 characters');
|
||||
if (!password || password.length < 4)
|
||||
throw new SenderError("Password must be at least 4 characters");
|
||||
|
||||
for (const u of ctx.db.user.iter()) {
|
||||
if (u.username === username) {
|
||||
throw new SenderError('Username already taken');
|
||||
throw new SenderError("Username already taken");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +311,7 @@ export const register = spacetimedb.reducer(
|
||||
...user,
|
||||
username,
|
||||
password,
|
||||
name: user.name || username
|
||||
name: user.name || username,
|
||||
});
|
||||
} else {
|
||||
ctx.db.user.insert({
|
||||
@@ -236,10 +322,10 @@ export const register = spacetimedb.reducer(
|
||||
online: true,
|
||||
talking: false,
|
||||
issuer: undefined,
|
||||
subject: undefined
|
||||
subject: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const login = spacetimedb.reducer(
|
||||
@@ -254,12 +340,16 @@ export const login = spacetimedb.reducer(
|
||||
}
|
||||
|
||||
if (!foundUser) {
|
||||
throw new SenderError('Invalid username or password');
|
||||
throw new SenderError("Invalid username or password");
|
||||
}
|
||||
|
||||
const currentIdentityUser = ctx.db.user.identity.find(ctx.sender);
|
||||
if (currentIdentityUser && currentIdentityUser.identity.toHexString() !== foundUser.identity.toHexString()) {
|
||||
ctx.db.user.identity.delete(ctx.sender);
|
||||
if (
|
||||
currentIdentityUser &&
|
||||
currentIdentityUser.identity.toHexString() !==
|
||||
foundUser.identity.toHexString()
|
||||
) {
|
||||
ctx.db.user.identity.delete(ctx.sender);
|
||||
}
|
||||
|
||||
if (foundUser.identity.toHexString() !== ctx.sender.toHexString()) {
|
||||
@@ -267,15 +357,15 @@ export const login = spacetimedb.reducer(
|
||||
ctx.db.user.insert({
|
||||
...foundUser,
|
||||
identity: ctx.sender,
|
||||
online: true
|
||||
online: true,
|
||||
});
|
||||
} else {
|
||||
ctx.db.user.identity.update({
|
||||
...foundUser,
|
||||
online: true
|
||||
online: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const create_server = spacetimedb.reducer(
|
||||
@@ -284,13 +374,27 @@ export const create_server = spacetimedb.reducer(
|
||||
validateName(name);
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || (!user.username && !user.subject)) {
|
||||
throw new SenderError('You must be logged in to create a server');
|
||||
throw new SenderError("You must be logged in to create a server");
|
||||
}
|
||||
const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender });
|
||||
ctx.db.server_member.insert({ id: 0n, identity: ctx.sender, server_id: s.id });
|
||||
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'Text' } });
|
||||
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'Voice' } });
|
||||
}
|
||||
ctx.db.server_member.insert({
|
||||
id: 0n,
|
||||
identity: ctx.sender,
|
||||
server_id: s.id,
|
||||
});
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: s.id,
|
||||
name: "general",
|
||||
kind: { tag: "Text" },
|
||||
});
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: s.id,
|
||||
name: "Voice General",
|
||||
kind: { tag: "Voice" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const join_server = spacetimedb.reducer(
|
||||
@@ -298,18 +402,22 @@ export const join_server = spacetimedb.reducer(
|
||||
(ctx, { serverId }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || (!user.username && !user.subject)) {
|
||||
throw new SenderError('You must be logged in to join a server');
|
||||
throw new SenderError("You must be logged in to join a server");
|
||||
}
|
||||
const s = ctx.db.server.id.find(serverId);
|
||||
if (!s) throw new SenderError('Server not found');
|
||||
if (!s) throw new SenderError("Server not found");
|
||||
|
||||
// Check if already a member
|
||||
for (const m of ctx.db.server_member.by_identity.filter(ctx.sender)) {
|
||||
if (m.server_id === serverId) return;
|
||||
}
|
||||
|
||||
ctx.db.server_member.insert({ id: 0n, identity: ctx.sender, server_id: serverId });
|
||||
}
|
||||
ctx.db.server_member.insert({
|
||||
id: 0n,
|
||||
identity: ctx.sender,
|
||||
server_id: serverId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const leave_server = spacetimedb.reducer(
|
||||
@@ -323,7 +431,7 @@ export const leave_server = spacetimedb.reducer(
|
||||
ctx.db.server_member.id.delete(m.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const create_channel = spacetimedb.reducer(
|
||||
@@ -332,17 +440,17 @@ export const create_channel = spacetimedb.reducer(
|
||||
validateName(name);
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || (!user.username && !user.subject)) {
|
||||
throw new SenderError('You must be logged in to create a channel');
|
||||
throw new SenderError("You must be logged in to create a channel");
|
||||
}
|
||||
const s = ctx.db.server.id.find(serverId);
|
||||
if (!s) throw new SenderError('Server not found');
|
||||
if (!s) throw new SenderError("Server not found");
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: serverId,
|
||||
name,
|
||||
kind: isVoice ? { tag: 'Voice' } : { tag: 'Text' }
|
||||
kind: isVoice ? { tag: "Voice" } : { tag: "Text" },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const join_voice = spacetimedb.reducer(
|
||||
@@ -350,21 +458,30 @@ export const join_voice = spacetimedb.reducer(
|
||||
(ctx, { channelId }) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || (!user.username && !user.subject)) {
|
||||
throw new SenderError('You must be logged in to join voice');
|
||||
throw new SenderError("You must be logged in to join voice");
|
||||
}
|
||||
const chan = ctx.db.channel.id.find(channelId);
|
||||
if (!chan || chan.kind.tag !== 'Voice') throw new SenderError('Invalid voice channel');
|
||||
if (!chan || chan.kind.tag !== "Voice")
|
||||
throw new SenderError("Invalid voice channel");
|
||||
|
||||
const existing = ctx.db.voice_state.identity.find(ctx.sender);
|
||||
if (existing) {
|
||||
if (existing.channel_id !== channelId) {
|
||||
clearSignalingForUser(ctx, ctx.sender);
|
||||
ctx.db.voice_state.identity.update({ identity: ctx.sender, channel_id: channelId, is_sharing_screen: false });
|
||||
ctx.db.voice_state.identity.update({
|
||||
identity: ctx.sender,
|
||||
channel_id: channelId,
|
||||
is_sharing_screen: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ctx.db.voice_state.insert({ identity: ctx.sender, channel_id: channelId, is_sharing_screen: false });
|
||||
ctx.db.voice_state.insert({
|
||||
identity: ctx.sender,
|
||||
channel_id: channelId,
|
||||
is_sharing_screen: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const set_sharing_screen = spacetimedb.reducer(
|
||||
@@ -372,9 +489,12 @@ export const set_sharing_screen = spacetimedb.reducer(
|
||||
(ctx, { sharing }) => {
|
||||
const state = ctx.db.voice_state.identity.find(ctx.sender);
|
||||
if (state) {
|
||||
ctx.db.voice_state.identity.update({ ...state, is_sharing_screen: sharing });
|
||||
ctx.db.voice_state.identity.update({
|
||||
...state,
|
||||
is_sharing_screen: sharing,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const start_watching = spacetimedb.reducer(
|
||||
@@ -386,8 +506,13 @@ export const start_watching = spacetimedb.reducer(
|
||||
for (const w of ctx.db.watching.by_watcher.filter(ctx.sender)) {
|
||||
if (w.watchee.isEqual(watchee)) return;
|
||||
}
|
||||
ctx.db.watching.insert({ id: 0n, watcher: ctx.sender, watchee, channel_id: channelId });
|
||||
}
|
||||
ctx.db.watching.insert({
|
||||
id: 0n,
|
||||
watcher: ctx.sender,
|
||||
watchee,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const stop_watching = spacetimedb.reducer(
|
||||
@@ -398,7 +523,7 @@ export const stop_watching = spacetimedb.reducer(
|
||||
ctx.db.watching.id.delete(w.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const leave_voice = spacetimedb.reducer((ctx) => {
|
||||
@@ -409,60 +534,149 @@ export const leave_voice = spacetimedb.reducer((ctx) => {
|
||||
clearSignalingForUser(ctx, ctx.sender);
|
||||
});
|
||||
|
||||
export const send_sdp_offer = spacetimedb.reducer(
|
||||
// --- Voice Signaling Reducers ---
|
||||
export const send_voice_sdp_offer = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, sdp, channelId }) => {
|
||||
// Clear any existing offers/answers/candidates between this pair in both directions
|
||||
// to ensure a fresh negotiation state.
|
||||
|
||||
// Outgoing from sender to receiver
|
||||
for (const offer of ctx.db.sdp_offer.by_sender.filter(ctx.sender)) {
|
||||
if (offer.receiver.isEqual(receiver)) ctx.db.sdp_offer.id.delete(offer.id);
|
||||
for (const row of ctx.db.voice_sdp_offer.by_sender.filter(ctx.sender)) {
|
||||
if (row.receiver.isEqual(receiver))
|
||||
ctx.db.voice_sdp_offer.id.delete(row.id);
|
||||
}
|
||||
for (const answer of ctx.db.sdp_answer.by_sender.filter(ctx.sender)) {
|
||||
if (answer.receiver.isEqual(receiver)) ctx.db.sdp_answer.id.delete(answer.id);
|
||||
for (const row of ctx.db.voice_sdp_answer.by_sender.filter(ctx.sender)) {
|
||||
if (row.receiver.isEqual(receiver))
|
||||
ctx.db.voice_sdp_answer.id.delete(row.id);
|
||||
}
|
||||
for (const cand of ctx.db.ice_candidate.by_sender.filter(ctx.sender)) {
|
||||
if (cand.receiver.isEqual(receiver)) ctx.db.ice_candidate.id.delete(cand.id);
|
||||
for (const row of ctx.db.voice_ice_candidate.by_sender.filter(ctx.sender)) {
|
||||
if (row.receiver.isEqual(receiver))
|
||||
ctx.db.voice_ice_candidate.id.delete(row.id);
|
||||
}
|
||||
|
||||
// Incoming to sender from receiver (stale messages from previous negotiations)
|
||||
for (const offer of ctx.db.sdp_offer.by_receiver.filter(ctx.sender)) {
|
||||
if (offer.sender.isEqual(receiver)) ctx.db.sdp_offer.id.delete(offer.id);
|
||||
for (const row of ctx.db.voice_sdp_offer.by_receiver.filter(ctx.sender)) {
|
||||
if (row.sender.isEqual(receiver))
|
||||
ctx.db.voice_sdp_offer.id.delete(row.id);
|
||||
}
|
||||
for (const answer of ctx.db.sdp_answer.by_receiver.filter(ctx.sender)) {
|
||||
if (answer.sender.isEqual(receiver)) ctx.db.sdp_answer.id.delete(answer.id);
|
||||
for (const row of ctx.db.voice_sdp_answer.by_receiver.filter(ctx.sender)) {
|
||||
if (row.sender.isEqual(receiver))
|
||||
ctx.db.voice_sdp_answer.id.delete(row.id);
|
||||
}
|
||||
for (const cand of ctx.db.ice_candidate.by_receiver.filter(ctx.sender)) {
|
||||
if (cand.sender.isEqual(receiver)) ctx.db.ice_candidate.id.delete(cand.id);
|
||||
for (const row of ctx.db.voice_ice_candidate.by_receiver.filter(
|
||||
ctx.sender,
|
||||
)) {
|
||||
if (row.sender.isEqual(receiver))
|
||||
ctx.db.voice_ice_candidate.id.delete(row.id);
|
||||
}
|
||||
|
||||
ctx.db.sdp_offer.insert({ id: 0n, sender: ctx.sender, receiver, sdp, channel_id: channelId });
|
||||
}
|
||||
ctx.db.voice_sdp_offer.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
sdp,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const send_sdp_answer = spacetimedb.reducer(
|
||||
export const send_voice_sdp_answer = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, sdp, channelId }) => {
|
||||
for (const answer of ctx.db.sdp_answer.by_sender.filter(ctx.sender)) {
|
||||
if (answer.receiver.isEqual(receiver)) {
|
||||
ctx.db.sdp_answer.id.delete(answer.id);
|
||||
}
|
||||
for (const row of ctx.db.voice_sdp_answer.by_sender.filter(ctx.sender)) {
|
||||
if (row.receiver.isEqual(receiver))
|
||||
ctx.db.voice_sdp_answer.id.delete(row.id);
|
||||
}
|
||||
ctx.db.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() },
|
||||
(ctx, { receiver, candidate, channelId }) => {
|
||||
ctx.db.ice_candidate.insert({ id: 0n, sender: ctx.sender, receiver, candidate, channel_id: channelId });
|
||||
}
|
||||
ctx.db.voice_ice_candidate.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
candidate,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// --- Screen Signaling Reducers ---
|
||||
export const send_screen_sdp_offer = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, sdp, channelId }) => {
|
||||
for (const row of ctx.db.screen_sdp_offer.by_sender.filter(ctx.sender)) {
|
||||
if (row.receiver.isEqual(receiver))
|
||||
ctx.db.screen_sdp_offer.id.delete(row.id);
|
||||
}
|
||||
for (const row of ctx.db.screen_sdp_answer.by_sender.filter(ctx.sender)) {
|
||||
if (row.receiver.isEqual(receiver))
|
||||
ctx.db.screen_sdp_answer.id.delete(row.id);
|
||||
}
|
||||
for (const row of ctx.db.screen_ice_candidate.by_sender.filter(
|
||||
ctx.sender,
|
||||
)) {
|
||||
if (row.receiver.isEqual(receiver))
|
||||
ctx.db.screen_ice_candidate.id.delete(row.id);
|
||||
}
|
||||
for (const row of ctx.db.screen_sdp_offer.by_receiver.filter(ctx.sender)) {
|
||||
if (row.sender.isEqual(receiver))
|
||||
ctx.db.screen_sdp_offer.id.delete(row.id);
|
||||
}
|
||||
for (const row of ctx.db.screen_sdp_answer.by_receiver.filter(ctx.sender)) {
|
||||
if (row.sender.isEqual(receiver))
|
||||
ctx.db.screen_sdp_answer.id.delete(row.id);
|
||||
}
|
||||
for (const row of ctx.db.screen_ice_candidate.by_receiver.filter(
|
||||
ctx.sender,
|
||||
)) {
|
||||
if (row.sender.isEqual(receiver))
|
||||
ctx.db.screen_ice_candidate.id.delete(row.id);
|
||||
}
|
||||
ctx.db.screen_sdp_offer.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
sdp,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const send_screen_sdp_answer = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, sdp, channelId }) => {
|
||||
for (const row of ctx.db.screen_sdp_answer.by_sender.filter(ctx.sender)) {
|
||||
if (row.receiver.isEqual(receiver))
|
||||
ctx.db.screen_sdp_answer.id.delete(row.id);
|
||||
}
|
||||
ctx.db.screen_sdp_answer.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
sdp,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const send_screen_ice_candidate = spacetimedb.reducer(
|
||||
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
|
||||
(ctx, { receiver, candidate, channelId }) => {
|
||||
ctx.db.screen_ice_candidate.insert({
|
||||
id: 0n,
|
||||
sender: ctx.sender,
|
||||
receiver,
|
||||
candidate,
|
||||
channel_id: channelId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
function clearSignalingForUser(ctx: any, identity: any) {
|
||||
// Clean up stale signaling messages for the user
|
||||
|
||||
// Clean up watching status
|
||||
for (const w of ctx.db.watching.by_watcher.filter(identity)) {
|
||||
ctx.db.watching.id.delete(w.id);
|
||||
@@ -471,26 +685,33 @@ function clearSignalingForUser(ctx: any, identity: any) {
|
||||
ctx.db.watching.id.delete(w.id);
|
||||
}
|
||||
|
||||
for (const offer of ctx.db.sdp_offer.by_sender.filter(identity)) {
|
||||
ctx.db.sdp_offer.id.delete(offer.id);
|
||||
}
|
||||
for (const offer of ctx.db.sdp_offer.by_receiver.filter(identity)) {
|
||||
ctx.db.sdp_offer.id.delete(offer.id);
|
||||
}
|
||||
// Voice Cleanup
|
||||
for (const row of ctx.db.voice_sdp_offer.by_sender.filter(identity))
|
||||
ctx.db.voice_sdp_offer.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_sdp_offer.by_receiver.filter(identity))
|
||||
ctx.db.voice_sdp_offer.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_sdp_answer.by_sender.filter(identity))
|
||||
ctx.db.voice_sdp_answer.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_sdp_answer.by_receiver.filter(identity))
|
||||
ctx.db.voice_sdp_answer.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_ice_candidate.by_sender.filter(identity))
|
||||
ctx.db.voice_ice_candidate.id.delete(row.id);
|
||||
for (const row of ctx.db.voice_ice_candidate.by_receiver.filter(identity))
|
||||
ctx.db.voice_ice_candidate.id.delete(row.id);
|
||||
|
||||
for (const answer of ctx.db.sdp_answer.by_sender.filter(identity)) {
|
||||
ctx.db.sdp_answer.id.delete(answer.id);
|
||||
}
|
||||
for (const answer of ctx.db.sdp_answer.by_receiver.filter(identity)) {
|
||||
ctx.db.sdp_answer.id.delete(answer.id);
|
||||
}
|
||||
|
||||
for (const candidate of ctx.db.ice_candidate.by_sender.filter(identity)) {
|
||||
ctx.db.ice_candidate.id.delete(candidate.id);
|
||||
}
|
||||
for (const candidate of ctx.db.ice_candidate.by_receiver.filter(identity)) {
|
||||
ctx.db.ice_candidate.id.delete(candidate.id);
|
||||
}
|
||||
// Screen Cleanup
|
||||
for (const row of ctx.db.screen_sdp_offer.by_sender.filter(identity))
|
||||
ctx.db.screen_sdp_offer.id.delete(row.id);
|
||||
for (const row of ctx.db.screen_sdp_offer.by_receiver.filter(identity))
|
||||
ctx.db.screen_sdp_offer.id.delete(row.id);
|
||||
for (const row of ctx.db.screen_sdp_answer.by_sender.filter(identity))
|
||||
ctx.db.screen_sdp_answer.id.delete(row.id);
|
||||
for (const row of ctx.db.screen_sdp_answer.by_receiver.filter(identity))
|
||||
ctx.db.screen_sdp_answer.id.delete(row.id);
|
||||
for (const row of ctx.db.screen_ice_candidate.by_sender.filter(identity))
|
||||
ctx.db.screen_ice_candidate.id.delete(row.id);
|
||||
for (const row of ctx.db.screen_ice_candidate.by_receiver.filter(identity))
|
||||
ctx.db.screen_ice_candidate.id.delete(row.id);
|
||||
}
|
||||
|
||||
export const create_thread = spacetimedb.reducer(
|
||||
@@ -499,23 +720,29 @@ export const create_thread = spacetimedb.reducer(
|
||||
validateName(name);
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || (!user.username && !user.subject)) {
|
||||
throw new SenderError('You must be logged in to create a thread');
|
||||
throw new SenderError("You must be logged in to create a thread");
|
||||
}
|
||||
const parentMsg = ctx.db.message.id.find(parentMessageId);
|
||||
if (!parentMsg) throw new SenderError('Parent message not found');
|
||||
if (!parentMsg) throw new SenderError("Parent message not found");
|
||||
|
||||
ctx.db.thread.insert({ id: 0n, channel_id: channelId, parent_message_id: parentMessageId, name });
|
||||
}
|
||||
ctx.db.thread.insert({
|
||||
id: 0n,
|
||||
channel_id: channelId,
|
||||
parent_message_id: parentMessageId,
|
||||
name,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const send_message = spacetimedb.reducer(
|
||||
{ text: t.string(), channelId: t.u64(), threadId: t.u64().optional() },
|
||||
(ctx, { text, channelId, threadId }) => {
|
||||
if (!text || text.trim().length === 0) throw new SenderError('Messages must not be empty');
|
||||
if (!text || text.trim().length === 0)
|
||||
throw new SenderError("Messages must not be empty");
|
||||
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (!user || (!user.username && !user.subject)) {
|
||||
throw new SenderError('You must be logged in to send messages');
|
||||
throw new SenderError("You must be logged in to send messages");
|
||||
}
|
||||
|
||||
ctx.db.message.insert({
|
||||
@@ -526,23 +753,37 @@ export const send_message = spacetimedb.reducer(
|
||||
channel_id: channelId,
|
||||
thread_id: threadId,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const init = spacetimedb.init(ctx => {
|
||||
export const init = spacetimedb.init((ctx) => {
|
||||
let hasServers = false;
|
||||
for (const _ of ctx.db.server.iter()) {
|
||||
hasServers = true;
|
||||
break;
|
||||
}
|
||||
if (!hasServers) {
|
||||
const s = ctx.db.server.insert({ id: 0n, name: 'Spacetime Community', owner: undefined });
|
||||
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'Text' } });
|
||||
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'Voice' } });
|
||||
const s = ctx.db.server.insert({
|
||||
id: 0n,
|
||||
name: "Spacetime Community",
|
||||
owner: undefined,
|
||||
});
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: s.id,
|
||||
name: "general",
|
||||
kind: { tag: "Text" },
|
||||
});
|
||||
ctx.db.channel.insert({
|
||||
id: 0n,
|
||||
server_id: s.id,
|
||||
name: "Voice General",
|
||||
kind: { tag: "Voice" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const onConnect = spacetimedb.clientConnected(ctx => {
|
||||
export const onConnect = spacetimedb.clientConnected((ctx) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
|
||||
if (ctx.senderAuth.hasJWT && ctx.senderAuth.jwt) {
|
||||
@@ -550,7 +791,11 @@ export const onConnect = spacetimedb.clientConnected(ctx => {
|
||||
const issuer = jwt.issuer;
|
||||
const subject = jwt.subject;
|
||||
const payload = jwt.fullPayload;
|
||||
const name = (payload.name as string) || (payload.nickname as string) || (payload.preferred_username as string) || (payload.email as string);
|
||||
const name =
|
||||
(payload.name as string) ||
|
||||
(payload.nickname as string) ||
|
||||
(payload.preferred_username as string) ||
|
||||
(payload.email as string);
|
||||
|
||||
if (user) {
|
||||
ctx.db.user.identity.update({
|
||||
@@ -559,7 +804,7 @@ export const onConnect = spacetimedb.clientConnected(ctx => {
|
||||
talking: false,
|
||||
name: user.name || name,
|
||||
issuer,
|
||||
subject
|
||||
subject,
|
||||
});
|
||||
} else {
|
||||
ctx.db.user.insert({
|
||||
@@ -570,7 +815,7 @@ export const onConnect = spacetimedb.clientConnected(ctx => {
|
||||
issuer,
|
||||
subject,
|
||||
username: undefined,
|
||||
password: undefined
|
||||
password: undefined,
|
||||
});
|
||||
}
|
||||
} else if (user) {
|
||||
@@ -585,21 +830,29 @@ export const onConnect = spacetimedb.clientConnected(ctx => {
|
||||
issuer: undefined,
|
||||
subject: undefined,
|
||||
username: undefined,
|
||||
password: undefined
|
||||
password: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-join the "Spacetime Community" server if it exists
|
||||
const communityServer = [...ctx.db.server.iter()].find(s => s.name === 'Spacetime Community');
|
||||
const communityServer = [...ctx.db.server.iter()].find(
|
||||
(s) => s.name === "Spacetime Community",
|
||||
);
|
||||
if (communityServer) {
|
||||
const alreadyMember = [...ctx.db.server_member.by_identity.filter(ctx.sender)].some(m => m.server_id === communityServer.id);
|
||||
const alreadyMember = [
|
||||
...ctx.db.server_member.by_identity.filter(ctx.sender),
|
||||
].some((m) => m.server_id === communityServer.id);
|
||||
if (!alreadyMember) {
|
||||
ctx.db.server_member.insert({ id: 0n, identity: ctx.sender, server_id: communityServer.id });
|
||||
ctx.db.server_member.insert({
|
||||
id: 0n,
|
||||
identity: ctx.sender,
|
||||
server_id: communityServer.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const onDisconnect = spacetimedb.clientDisconnected(ctx => {
|
||||
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => {
|
||||
const user = ctx.db.user.identity.find(ctx.sender);
|
||||
if (user) {
|
||||
ctx.db.user.identity.update({ ...user, online: false, talking: false });
|
||||
|
||||
+36
-26
@@ -20,7 +20,8 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'gg sans', 'Noto Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-family:
|
||||
"gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--text-normal);
|
||||
height: 100vh;
|
||||
@@ -58,7 +59,9 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-radius 0.2s, background-color 0.2s;
|
||||
transition:
|
||||
border-radius 0.2s,
|
||||
background-color 0.2s;
|
||||
font-weight: bold;
|
||||
color: var(--text-normal);
|
||||
position: relative;
|
||||
@@ -77,7 +80,7 @@ body {
|
||||
}
|
||||
|
||||
.server-icon.active::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
width: 8px;
|
||||
@@ -111,7 +114,7 @@ body {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -271,7 +274,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -290,7 +293,7 @@ body {
|
||||
}
|
||||
|
||||
.message-item:hover {
|
||||
background-color: rgba(0,0,0,0.05);
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
@@ -460,7 +463,6 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.video-tile.talking {
|
||||
border-color: #23a559;
|
||||
}
|
||||
@@ -472,7 +474,8 @@ body {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.video-controls, .tile-actions-right {
|
||||
.video-controls,
|
||||
.tile-actions-right {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -496,7 +499,8 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.watch-btn, .mute-tile-btn {
|
||||
.watch-btn,
|
||||
.mute-tile-btn {
|
||||
background-color: var(--brand);
|
||||
color: white;
|
||||
border: none;
|
||||
@@ -509,17 +513,18 @@ body {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.watch-btn.active, .mute-tile-btn {
|
||||
.watch-btn.active,
|
||||
.mute-tile-btn {
|
||||
background-color: rgba(30, 31, 34, 0.7);
|
||||
}
|
||||
|
||||
.watch-btn.active:hover, .mute-tile-btn:hover {
|
||||
.watch-btn.active:hover,
|
||||
.mute-tile-btn:hover {
|
||||
background-color: rgba(43, 45, 49, 0.9);
|
||||
}
|
||||
|
||||
|
||||
.fullscreen-btn {
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
@@ -550,7 +555,7 @@ body {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
@@ -633,15 +638,15 @@ body {
|
||||
background-color: var(--background-floating, #1e1f22);
|
||||
border-radius: 8px;
|
||||
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;
|
||||
color: var(--text-normal, #dbdee1);
|
||||
font-family: 'gg sans', sans-serif;
|
||||
font-family: "gg sans", sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.connection-popover::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 20px;
|
||||
@@ -657,7 +662,7 @@ body {
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
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 {
|
||||
@@ -671,9 +676,15 @@ body {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.popover-status.green { color: #23a559; }
|
||||
.popover-status.yellow { color: #f0b232; }
|
||||
.popover-status.red { color: #f23f43; }
|
||||
.popover-status.green {
|
||||
color: #23a559;
|
||||
}
|
||||
.popover-status.yellow {
|
||||
color: #f0b232;
|
||||
}
|
||||
.popover-status.red {
|
||||
color: #f23f43;
|
||||
}
|
||||
|
||||
.popover-info {
|
||||
font-size: 0.8rem;
|
||||
@@ -709,7 +720,6 @@ body {
|
||||
}
|
||||
|
||||
.member-name {
|
||||
...
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
@@ -724,7 +734,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
@@ -738,7 +748,7 @@ body {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -755,7 +765,7 @@ body {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0,0,0,0.8);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -806,7 +816,7 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import App from './App';
|
||||
import { SpacetimeDBProvider } from 'spacetimedb/react';
|
||||
import { DbConnection } from './module_bindings';
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import App from "./App";
|
||||
import { SpacetimeDBProvider } from "spacetimedb/react";
|
||||
import { DbConnection } from "./module_bindings";
|
||||
|
||||
describe('App Integration Test', () => {
|
||||
it('connects to the DB, allows name change and message sending', async () => {
|
||||
describe("App Integration Test", () => {
|
||||
it("connects to the DB, allows name change and message sending", async () => {
|
||||
const connectionBuilder = DbConnection.builder()
|
||||
.withUri('ws://localhost:3000')
|
||||
.withDatabaseName('quickstart-chat')
|
||||
.withUri("ws://localhost:3000")
|
||||
.withDatabaseName("quickstart-chat")
|
||||
.withToken(
|
||||
localStorage.getItem(
|
||||
'ws://localhost:3000/quickstart-chat/auth_token'
|
||||
) || ''
|
||||
"ws://localhost:3000/quickstart-chat/auth_token",
|
||||
) || "",
|
||||
);
|
||||
render(
|
||||
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
|
||||
<App />
|
||||
</SpacetimeDBProvider>
|
||||
</SpacetimeDBProvider>,
|
||||
);
|
||||
|
||||
// Initially, we should see "Connecting..."
|
||||
@@ -29,7 +29,7 @@ describe('App Integration Test', () => {
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.queryByText(/Connecting.../i)).not.toBeInTheDocument(),
|
||||
{ timeout: 10000 }
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
// The profile section should show the default name or truncated identity
|
||||
@@ -37,40 +37,40 @@ describe('App Integration Test', () => {
|
||||
// If your default identity is something like 'abcdef12' or 'Unknown'
|
||||
// we do a generic check:
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /profile/i })
|
||||
screen.getByRole("heading", { name: /profile/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Let's change the user's name
|
||||
const editNameButton = screen.getByText(/Edit Name/i);
|
||||
await userEvent.click(editNameButton);
|
||||
|
||||
const nameInput = screen.getByRole('textbox', { name: /name input/i });
|
||||
const nameInput = screen.getByRole("textbox", { name: /name input/i });
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'TestUser');
|
||||
const submitNameButton = screen.getByRole('button', { name: /submit/i });
|
||||
await userEvent.type(nameInput, "TestUser");
|
||||
const submitNameButton = screen.getByRole("button", { name: /submit/i });
|
||||
await userEvent.click(submitNameButton);
|
||||
|
||||
// If your DB or UI updates instantly, we can check that the new name shows up
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('TestUser')).toBeInTheDocument();
|
||||
expect(screen.getByText("TestUser")).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
// Now let's send a message
|
||||
const textarea = screen.getByRole('textbox', { name: /message input/i });
|
||||
await userEvent.type(textarea, 'Hello from GH Actions!');
|
||||
const textarea = screen.getByRole("textbox", { name: /message input/i });
|
||||
await userEvent.type(textarea, "Hello from GH Actions!");
|
||||
|
||||
const sendButton = screen.getByRole('button', { name: /send/i });
|
||||
const sendButton = screen.getByRole("button", { name: /send/i });
|
||||
await userEvent.click(sendButton);
|
||||
|
||||
// Wait for message to appear in the UI
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('Hello from GH Actions!')).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello from GH Actions!")).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-6
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import './App.css';
|
||||
import React from "react";
|
||||
import "./App.css";
|
||||
// Remove all imports related to SpacetimeDB, auth, and chat logic that are now in ChatContainer or other modules
|
||||
// import { tables, reducers } from './module_bindings';
|
||||
// import type * as Types from './module_bindings/types';
|
||||
@@ -9,16 +9,14 @@ import './App.css';
|
||||
// import { TOKEN_KEY } from './main';
|
||||
|
||||
// Import the new ChatContainer component
|
||||
import { ChatContainer } from './chat'; // Import from index.ts
|
||||
import { ChatContainer } from "./chat"; // Import from index.ts
|
||||
|
||||
function App() {
|
||||
// All the state, effects, reducers, table fetches, and UI rendering logic
|
||||
// related to chat and authentication have been moved to ChatContainer and its sub-components.
|
||||
// App.tsx now simply renders the ChatContainer.
|
||||
|
||||
return (
|
||||
<ChatContainer />
|
||||
);
|
||||
return <ChatContainer />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
+19
-13
@@ -1,9 +1,9 @@
|
||||
// src/auth/AuthGate.tsx
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import UsernamePasswordAuth from './UsernamePasswordAuth';
|
||||
import App from '../App';
|
||||
import { TOKEN_KEY } from '../main.tsx';
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import UsernamePasswordAuth from "./UsernamePasswordAuth";
|
||||
import App from "../App";
|
||||
import { TOKEN_KEY } from "../main.tsx";
|
||||
|
||||
interface AuthGateProps {
|
||||
children: React.ReactNode; // This will be SpacetimeDBWrapper
|
||||
@@ -12,12 +12,14 @@ interface AuthGateProps {
|
||||
function AuthGate({ children }: AuthGateProps) {
|
||||
const auth = useAuth();
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [hasStoredToken, setHasStoredToken] = useState(!!localStorage.getItem(TOKEN_KEY));
|
||||
const [hasStoredToken, setHasStoredToken] = useState(
|
||||
!!localStorage.getItem(TOKEN_KEY),
|
||||
);
|
||||
|
||||
// Logging authentication state
|
||||
console.log('AuthGate: auth.isLoading:', auth.isLoading);
|
||||
console.log('AuthGate: auth.isAuthenticated:', auth.isAuthenticated);
|
||||
console.log('AuthGate: hasStoredToken:', hasStoredToken);
|
||||
console.log("AuthGate: auth.isLoading:", auth.isLoading);
|
||||
console.log("AuthGate: auth.isAuthenticated:", auth.isAuthenticated);
|
||||
console.log("AuthGate: hasStoredToken:", hasStoredToken);
|
||||
|
||||
const handleUsernamePasswordLoginSuccess = () => {
|
||||
console.log("Username/Password login successful. AuthGate will re-render.");
|
||||
@@ -25,7 +27,9 @@ function AuthGate({ children }: AuthGateProps) {
|
||||
};
|
||||
|
||||
const handleUsernamePasswordRegisterSuccess = () => {
|
||||
console.log("Username/Password registration successful. AuthGate will re-render.");
|
||||
console.log(
|
||||
"Username/Password registration successful. AuthGate will re-render.",
|
||||
);
|
||||
setHasStoredToken(true);
|
||||
};
|
||||
|
||||
@@ -57,15 +61,17 @@ function AuthGate({ children }: AuthGateProps) {
|
||||
<div className="login-card">
|
||||
<h1>Welcome!</h1>
|
||||
<p>Please log in to continue to the chat.</p>
|
||||
{authError && <p style={{ color: '#da373c', marginBottom: '10px' }}>{authError}</p>}
|
||||
{authError && (
|
||||
<p style={{ color: "#da373c", marginBottom: "10px" }}>{authError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => auth.signinRedirect()}
|
||||
disabled={auth.isLoading}
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', marginBottom: '10px' }}
|
||||
style={{ width: "100%", marginBottom: "10px" }}
|
||||
>
|
||||
{auth.isLoading ? 'Loading...' : 'Login with OIDC'}
|
||||
{auth.isLoading ? "Loading..." : "Login with OIDC"}
|
||||
</button>
|
||||
|
||||
<UsernamePasswordAuth
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// src/auth/OidcProvider.tsx
|
||||
import { ReactNode } from 'react';
|
||||
import { AuthProvider } from 'react-oidc-context';
|
||||
import { ReactNode } from "react";
|
||||
import { AuthProvider } from "react-oidc-context";
|
||||
|
||||
// OIDC Configuration - User should replace these with their own provider values
|
||||
export const oidcConfig = {
|
||||
authority: import.meta.env.VITE_OIDC_AUTHORITY ?? "https://accounts.google.com",
|
||||
authority:
|
||||
import.meta.env.VITE_OIDC_AUTHORITY ?? "https://accounts.google.com",
|
||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID ?? "REPLACE_ME",
|
||||
redirect_uri: window.location.origin,
|
||||
scope: "openid profile email",
|
||||
response_type: "code",
|
||||
onSigninCallback: () => {
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
interface OidcProviderProps {
|
||||
@@ -19,9 +20,5 @@ interface OidcProviderProps {
|
||||
}
|
||||
|
||||
export function OidcProvider({ children }: OidcProviderProps) {
|
||||
return (
|
||||
<AuthProvider {...oidcConfig}>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
);
|
||||
return <AuthProvider {...oidcConfig}>{children}</AuthProvider>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/auth/UsernamePasswordAuth.tsx
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import { useSpacetimeDB } from 'spacetimedb/react'; // Correct hook for SpacetimeDB connection
|
||||
import { TOKEN_KEY } from '../main.tsx'; // Import the token key
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { useSpacetimeDB } from "spacetimedb/react"; // Correct hook for SpacetimeDB connection
|
||||
import { TOKEN_KEY } from "../main.tsx"; // Import the token key
|
||||
|
||||
// Define the expected shape of the DbConnection instance from the hook
|
||||
interface SpacetimeDBConnection {
|
||||
@@ -16,9 +16,13 @@ interface UsernamePasswordAuthProps {
|
||||
onError: (error: string | null) => void; // Callback for errors
|
||||
}
|
||||
|
||||
function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: UsernamePasswordAuthProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
function UsernamePasswordAuth({
|
||||
onLoginSuccess,
|
||||
onRegisterSuccess,
|
||||
onError,
|
||||
}: UsernamePasswordAuthProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Get the SpacetimeDB connection instance using the correct hook
|
||||
@@ -26,11 +30,11 @@ function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: Us
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!conn) {
|
||||
onError('Database connection not available.');
|
||||
onError("Database connection not available.");
|
||||
return;
|
||||
}
|
||||
if (!username || !password) {
|
||||
onError('Please enter both username and password.');
|
||||
onError("Please enter both username and password.");
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
@@ -45,8 +49,8 @@ function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: Us
|
||||
// The actual state update will come through the subscription.
|
||||
onLoginSuccess();
|
||||
} catch (e: any) {
|
||||
onError(`Login error: ${e.message || 'Unknown error'}`);
|
||||
console.error('Login error:', e);
|
||||
onError(`Login error: ${e.message || "Unknown error"}`);
|
||||
console.error("Login error:", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -54,11 +58,11 @@ function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: Us
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!conn) {
|
||||
onError('Database connection not available.');
|
||||
onError("Database connection not available.");
|
||||
return;
|
||||
}
|
||||
if (!username || !password) {
|
||||
onError('Please enter both username and password.');
|
||||
onError("Please enter both username and password.");
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
@@ -67,41 +71,75 @@ function UsernamePasswordAuth({ onLoginSuccess, onRegisterSuccess, onError }: Us
|
||||
conn.reducers.register({ username, password });
|
||||
onRegisterSuccess();
|
||||
} catch (e: any) {
|
||||
onError(`Registration error: ${e.message || 'Unknown error'}`);
|
||||
console.error('Registration error:', e);
|
||||
onError(`Registration error: ${e.message || "Unknown error"}`);
|
||||
console.error("Registration error:", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<h3 style={{ marginTop: '0' }}>Username/Password Authentication</h3>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: "0" }}>Username/Password Authentication</h3>
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
style={{ padding: '8px', marginRight: '10px', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||
style={{
|
||||
padding: "8px",
|
||||
marginRight: "10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ccc",
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{ padding: '8px', marginRight: '10px', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||
style={{
|
||||
padding: "8px",
|
||||
marginRight: "10px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ccc",
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleLogin} disabled={isLoading || !conn} style={{ padding: '10px 15px', marginRight: '10px', borderRadius: '4px', cursor: isLoading || !conn ? 'not-allowed' : 'pointer' }}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading || !conn}
|
||||
style={{
|
||||
padding: "10px 15px",
|
||||
marginRight: "10px",
|
||||
borderRadius: "4px",
|
||||
cursor: isLoading || !conn ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Logging in..." : "Login"}
|
||||
</button>
|
||||
<button onClick={handleRegister} disabled={isLoading || !conn} style={{ padding: '10px 15px', borderRadius: '4px', cursor: isLoading || !conn ? 'not-allowed' : 'pointer' }}>
|
||||
{isLoading ? 'Registering...' : 'Register'}
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
disabled={isLoading || !conn}
|
||||
style={{
|
||||
padding: "10px 15px",
|
||||
borderRadius: "4px",
|
||||
cursor: isLoading || !conn ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Registering..." : "Register"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
// src/auth/index.ts
|
||||
export { OidcProvider, oidcConfig } from './OidcProvider';
|
||||
export { default as AuthGate } from './AuthGate';
|
||||
export { default as UsernamePasswordAuth } from './UsernamePasswordAuth';
|
||||
export { OidcProvider, oidcConfig } from "./OidcProvider";
|
||||
export { default as AuthGate } from "./AuthGate";
|
||||
export { default as UsernamePasswordAuth } from "./UsernamePasswordAuth";
|
||||
|
||||
+51
-40
@@ -1,15 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useChat, useWebRTC } from './services';
|
||||
import ServerList from './components/ServerList';
|
||||
import ChannelList from './components/ChannelList';
|
||||
import MessageList from './components/MessageList';
|
||||
import MessageInput from './components/MessageInput';
|
||||
import MemberList from './components/MemberList';
|
||||
import ThreadView from './components/ThreadView';
|
||||
import ServerDiscovery from './components/ServerDiscovery';
|
||||
import { VideoGrid } from './components/VideoGrid';
|
||||
import { SettingsPanel } from './components/SettingsPanel';
|
||||
import { useSpacetimeDB } from 'spacetimedb/react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useChat, useWebRTC } from "./services";
|
||||
import ServerList from "./components/ServerList";
|
||||
import ChannelList from "./components/ChannelList";
|
||||
import MessageList from "./components/MessageList";
|
||||
import MessageInput from "./components/MessageInput";
|
||||
import MemberList from "./components/MemberList";
|
||||
import ThreadView from "./components/ThreadView";
|
||||
import ServerDiscovery from "./components/ServerDiscovery";
|
||||
import { VideoGrid } from "./components/VideoGrid";
|
||||
import { SettingsPanel } from "./components/SettingsPanel";
|
||||
import { useSpacetimeDB } from "spacetimedb/react";
|
||||
|
||||
const ChatContainer: React.FC = () => {
|
||||
const chat = useChat();
|
||||
@@ -31,7 +31,7 @@ const ChatContainer: React.FC = () => {
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
peerStats
|
||||
peerStats,
|
||||
} = useWebRTC(chat.connectedVoiceChannel?.id);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +89,7 @@ const ChatContainer: React.FC = () => {
|
||||
<div className="voice-status-bar">
|
||||
<div className="voice-info">
|
||||
<div className="voice-connected-text">
|
||||
<span style={{ marginRight: '4px' }}>📶</span>
|
||||
<span style={{ marginRight: "4px" }}>📶</span>
|
||||
Voice Connected
|
||||
</div>
|
||||
<div className="voice-channel-name">
|
||||
@@ -101,9 +101,9 @@ const ChatContainer: React.FC = () => {
|
||||
className="icon-btn"
|
||||
onClick={chat.handleLeaveVoice}
|
||||
title="Disconnect"
|
||||
style={{ color: '#f23f43' }}
|
||||
style={{ color: "#f23f43" }}
|
||||
>
|
||||
<span style={{ fontSize: '1.2rem' }}>✕</span>
|
||||
<span style={{ fontSize: "1.2rem" }}>✕</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,29 +113,31 @@ const ChatContainer: React.FC = () => {
|
||||
<div className="user-info-bar">
|
||||
<div className="user-info-main">
|
||||
<div className="avatar small">
|
||||
{chat.currentUser?.name?.[0]?.toUpperCase() || identity?.toHexString().substring(0, 2).toUpperCase()}
|
||||
{chat.currentUser?.name?.[0]?.toUpperCase() ||
|
||||
identity?.toHexString().substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-display-name">
|
||||
{chat.currentUser?.name || identity?.toHexString().substring(0, 8)}
|
||||
{chat.currentUser?.name ||
|
||||
identity?.toHexString().substring(0, 8)}
|
||||
</div>
|
||||
<div className="user-status">Online</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-actions">
|
||||
<button
|
||||
className={`icon-btn ${isMuted ? 'active' : ''}`}
|
||||
className={`icon-btn ${isMuted ? "active" : ""}`}
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? '🎙️❌' : '🎙️'}
|
||||
{isMuted ? "🎙️❌" : "🎙️"}
|
||||
</button>
|
||||
<button
|
||||
className={`icon-btn ${isDeafened ? 'active' : ''}`}
|
||||
className={`icon-btn ${isDeafened ? "active" : ""}`}
|
||||
onClick={toggleDeafen}
|
||||
title={isDeafened ? "Undeafen" : "Deafen"}
|
||||
>
|
||||
{isDeafened ? '🎧❌' : '🎧'}
|
||||
{isDeafened ? "🎧❌" : "🎧"}
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
@@ -148,28 +150,38 @@ const ChatContainer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`main-content ${ (showMemberList || chat.activeThreadId) ? 'has-right-sidebar' : ''}`}>
|
||||
<div
|
||||
className={`main-content ${showMemberList || chat.activeThreadId ? "has-right-sidebar" : ""}`}
|
||||
>
|
||||
<div className="chat-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<span className="channel-item-hash">
|
||||
{chat.isActiveChannelVoice ? '🔊' : '#'}
|
||||
{chat.isActiveChannelVoice ? "🔊" : "#"}
|
||||
</span>
|
||||
{chat.activeChannel?.name || 'Select a channel'}
|
||||
{chat.activeChannel?.name || "Select a channel"}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{chat.isActiveChannelVoice && chat.connectedVoiceChannel?.id === chat.activeChannel?.id && (
|
||||
<button
|
||||
className={`screen-share-btn ${isSharingScreen ? 'active' : ''}`}
|
||||
onClick={isSharingScreen ? stopScreenShare : startScreenShare}
|
||||
>
|
||||
{isSharingScreen ? 'Stop Sharing' : 'Share Screen'}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
{chat.isActiveChannelVoice &&
|
||||
chat.connectedVoiceChannel?.id === chat.activeChannel?.id && (
|
||||
<button
|
||||
className={`screen-share-btn ${isSharingScreen ? "active" : ""}`}
|
||||
onClick={isSharingScreen ? stopScreenShare : startScreenShare}
|
||||
>
|
||||
{isSharingScreen ? "Stop Sharing" : "Share Screen"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!chat.activeThreadId && (
|
||||
<button
|
||||
className={`icon-btn ${showMemberList ? 'active' : ''}`}
|
||||
className={`icon-btn ${showMemberList ? "active" : ""}`}
|
||||
onClick={() => setShowMemberList(!showMemberList)}
|
||||
title={showMemberList ? "Hide Member List" : "Show Member List"}
|
||||
>
|
||||
@@ -229,7 +241,8 @@ const ChatContainer: React.FC = () => {
|
||||
voiceStates={chat.voiceStates}
|
||||
currentVoiceState={chat.currentVoiceState}
|
||||
connectedVoiceChannel={chat.connectedVoiceChannel}
|
||||
/> )
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{chat.showDiscoveryModal && (
|
||||
@@ -241,9 +254,7 @@ const ChatContainer: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
<SettingsPanel onClose={() => setShowSettings(false)} />
|
||||
)}
|
||||
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+252
-117
@@ -33,16 +33,27 @@ interface ChannelListProps {
|
||||
}
|
||||
|
||||
// Helper function (extracted from App.tsx)
|
||||
const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]) => {
|
||||
const getUsername = (
|
||||
userIdentity: Identity | null,
|
||||
users: readonly Types.User[],
|
||||
) => {
|
||||
if (!userIdentity) return "Unknown";
|
||||
const user = users.find(u => u.identity?.isEqual(userIdentity));
|
||||
const user = users.find((u) => u.identity?.isEqual(userIdentity));
|
||||
return user?.name || userIdentity.toHexString().substring(0, 8);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string | undefined): "green" | "yellow" | "red" => {
|
||||
if (status === 'connected' || status === 'completed') return 'green';
|
||||
if (status === 'connecting' || status === 'checking' || status === 'new' || !status) return 'yellow';
|
||||
return 'red';
|
||||
const getStatusColor = (
|
||||
status: string | undefined,
|
||||
): "green" | "yellow" | "red" => {
|
||||
if (status === "connected" || status === "completed") return "green";
|
||||
if (
|
||||
status === "connecting" ||
|
||||
status === "checking" ||
|
||||
status === "new" ||
|
||||
!status
|
||||
)
|
||||
return "yellow";
|
||||
return "red";
|
||||
};
|
||||
|
||||
const formatBitrate = (bps: number) => {
|
||||
@@ -51,12 +62,19 @@ const formatBitrate = (bps: number) => {
|
||||
return `${bps.toFixed(0)} bps`;
|
||||
};
|
||||
|
||||
const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: string, isMe: boolean }> = ({ stats, status, name, isMe }) => {
|
||||
const ConnectionPopover: React.FC<{
|
||||
stats?: WebRTCStats;
|
||||
status: string;
|
||||
name: string;
|
||||
isMe: boolean;
|
||||
}> = ({ stats, status, name, isMe }) => {
|
||||
return (
|
||||
<div className="connection-popover">
|
||||
<div className="popover-header">
|
||||
<span className="popover-name">{name}</span>
|
||||
<span className={`popover-status ${getStatusColor(status)}`}>{status}</span>
|
||||
<span className={`popover-status ${getStatusColor(status)}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isMe ? (
|
||||
@@ -65,16 +83,36 @@ const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: s
|
||||
<div className="popover-stats">
|
||||
<div className="stats-section">
|
||||
<div className="section-title">AUDIO</div>
|
||||
<div className="stat-row"><span>Bitrate</span><span>{formatBitrate(stats.audio.bitrate)}</span></div>
|
||||
<div className="stat-row"><span>Jitter</span><span>{(stats.audio.jitter * 1000).toFixed(2)} ms</span></div>
|
||||
<div className="stat-row"><span>Loss</span><span>{stats.audio.packetsLost} pkts</span></div>
|
||||
<div className="stat-row">
|
||||
<span>Bitrate</span>
|
||||
<span>{formatBitrate(stats.audio.bitrate)}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>Jitter</span>
|
||||
<span>{(stats.audio.jitter * 1000).toFixed(2)} ms</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>Loss</span>
|
||||
<span>{stats.audio.packetsLost} pkts</span>
|
||||
</div>
|
||||
</div>
|
||||
{stats.video.bitrate > 0 && (
|
||||
<div className="stats-section">
|
||||
<div className="section-title">VIDEO</div>
|
||||
<div className="stat-row"><span>Bitrate</span><span>{formatBitrate(stats.video.bitrate)}</span></div>
|
||||
<div className="stat-row"><span>Res</span><span>{stats.video.frameWidth}x{stats.video.frameHeight}</span></div>
|
||||
<div className="stat-row"><span>FPS</span><span>{stats.video.framesPerSecond.toFixed(0)}</span></div>
|
||||
<div className="stat-row">
|
||||
<span>Bitrate</span>
|
||||
<span>{formatBitrate(stats.video.bitrate)}</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>Res</span>
|
||||
<span>
|
||||
{stats.video.frameWidth}x{stats.video.frameHeight}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>FPS</span>
|
||||
<span>{stats.video.framesPerSecond.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -86,25 +124,51 @@ const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: s
|
||||
};
|
||||
|
||||
export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
activeServerId, activeChannelId, setActiveChannelId, setActiveThreadId,
|
||||
channels, servers, users, identity, voiceStates, currentVoiceState, connectedVoiceChannel, isFullyAuthenticated,
|
||||
showCreateChannelModal, setShowCreateChannelModal, newChannelName, setNewChannelName, isVoiceChannel, setIsVoiceChannel,
|
||||
handleCreateChannel, handleJoinVoice, handleLeaveVoice, peerStatuses, watching, peerStats
|
||||
activeServerId,
|
||||
activeChannelId,
|
||||
setActiveChannelId,
|
||||
setActiveThreadId,
|
||||
channels,
|
||||
servers,
|
||||
users,
|
||||
identity,
|
||||
voiceStates,
|
||||
currentVoiceState,
|
||||
connectedVoiceChannel,
|
||||
isFullyAuthenticated,
|
||||
showCreateChannelModal,
|
||||
setShowCreateChannelModal,
|
||||
newChannelName,
|
||||
setNewChannelName,
|
||||
isVoiceChannel,
|
||||
setIsVoiceChannel,
|
||||
handleCreateChannel,
|
||||
handleJoinVoice,
|
||||
handleLeaveVoice,
|
||||
peerStatuses,
|
||||
watching,
|
||||
peerStats,
|
||||
}) => {
|
||||
const [hoveredPeer, setHoveredPeer] = React.useState<string | null>(null);
|
||||
const activeServer = React.useMemo(() =>
|
||||
servers.find(s => s.id === activeServerId),
|
||||
[servers, activeServerId]
|
||||
const activeServer = React.useMemo(
|
||||
() => servers.find((s) => s.id === activeServerId),
|
||||
[servers, activeServerId],
|
||||
);
|
||||
|
||||
const textChannels = React.useMemo(() =>
|
||||
channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Text'),
|
||||
[channels, activeServerId]
|
||||
const textChannels = React.useMemo(
|
||||
() =>
|
||||
channels.filter(
|
||||
(c) => c.serverId === activeServerId && c.kind.tag === "Text",
|
||||
),
|
||||
[channels, activeServerId],
|
||||
);
|
||||
|
||||
const voiceChannels = React.useMemo(() =>
|
||||
channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Voice'),
|
||||
[channels, activeServerId]
|
||||
const voiceChannels = React.useMemo(
|
||||
() =>
|
||||
channels.filter(
|
||||
(c) => c.serverId === activeServerId && c.kind.tag === "Voice",
|
||||
),
|
||||
[channels, activeServerId],
|
||||
);
|
||||
|
||||
if (!activeServer) {
|
||||
@@ -124,12 +188,20 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
<div className="channel-section">
|
||||
<div className="section-header">
|
||||
<span>TEXT CHANNELS</span>
|
||||
<button className="add-btn" onClick={() => { setIsVoiceChannel(false); setShowCreateChannelModal(true); }}>+</button>
|
||||
<button
|
||||
className="add-btn"
|
||||
onClick={() => {
|
||||
setIsVoiceChannel(false);
|
||||
setShowCreateChannelModal(true);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{textChannels.map(channel => (
|
||||
{textChannels.map((channel) => (
|
||||
<div
|
||||
key={channel.id.toString()}
|
||||
className={`channel-item ${activeChannelId === channel.id ? 'active' : ''}`}
|
||||
className={`channel-item ${activeChannelId === channel.id ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveChannelId(channel.id);
|
||||
setActiveThreadId(null);
|
||||
@@ -144,112 +216,164 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
<div className="channel-section">
|
||||
<div className="section-header">
|
||||
<span>VOICE CHANNELS</span>
|
||||
<button className="add-btn" onClick={() => { setIsVoiceChannel(true); setShowCreateChannelModal(true); }}>+</button>
|
||||
<button
|
||||
className="add-btn"
|
||||
onClick={() => {
|
||||
setIsVoiceChannel(true);
|
||||
setShowCreateChannelModal(true);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{voiceChannels.map(channel => (
|
||||
{voiceChannels.map((channel) => (
|
||||
<div key={channel.id.toString()}>
|
||||
<div
|
||||
className={`channel-item ${activeChannelId === channel.id ? 'active' : ''}`}
|
||||
className={`channel-item ${activeChannelId === channel.id ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
handleJoinVoice(channel.id);
|
||||
setActiveChannelId(channel.id);
|
||||
setActiveThreadId(null);
|
||||
}}
|
||||
style={{cursor: isFullyAuthenticated ? 'pointer' : 'not-allowed'}}
|
||||
style={{
|
||||
cursor: isFullyAuthenticated ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
<span className="channel-item-hash">🔊</span>
|
||||
{channel.name}
|
||||
</div>
|
||||
|
||||
{/* Voice Channel Members */}
|
||||
<div style={{paddingLeft: '16px', display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px'}}>
|
||||
{voiceStates.filter(vs => vs.channelId === channel.id).map(vs => {
|
||||
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));
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: "16px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
{voiceStates
|
||||
.filter((vs) => vs.channelId === channel.id)
|
||||
.map((vs) => {
|
||||
const peerIdHex = vs.identity.toHexString();
|
||||
const isMe = identity?.isEqual(vs.identity);
|
||||
const status = peerStatuses.get(peerIdHex);
|
||||
const user = users.find((u) =>
|
||||
u.identity?.isEqual(vs.identity),
|
||||
);
|
||||
const 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 videoStatusColor = isMe ? (isSharing ? 'green' : undefined) : (isSharing ? getStatusColor(status) : undefined);
|
||||
const voiceStatusColor = isMe
|
||||
? "green"
|
||||
: getStatusColor(status);
|
||||
const videoStatusColor = isMe
|
||||
? isSharing
|
||||
? "green"
|
||||
: undefined
|
||||
: isSharing
|
||||
? getStatusColor(status)
|
||||
: undefined;
|
||||
|
||||
// Consolidate into one dot: priority Red > Yellow > Green
|
||||
let finalStatusColor: "green" | "yellow" | "red" = voiceStatusColor;
|
||||
if (videoStatusColor === 'red') finalStatusColor = 'red';
|
||||
else if (videoStatusColor === 'yellow' && finalStatusColor === 'green') finalStatusColor = 'yellow';
|
||||
// Consolidate into one dot: priority Red > Yellow > Green
|
||||
let finalStatusColor: "green" | "yellow" | "red" =
|
||||
voiceStatusColor;
|
||||
if (videoStatusColor === "red") finalStatusColor = "red";
|
||||
else if (
|
||||
videoStatusColor === "yellow" &&
|
||||
finalStatusColor === "green"
|
||||
)
|
||||
finalStatusColor = "yellow";
|
||||
|
||||
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'
|
||||
}}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
className="avatar"
|
||||
key={peerIdHex}
|
||||
className="voice-member-item"
|
||||
style={{
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
fontSize: '0.5rem',
|
||||
backgroundColor: 'var(--brand)',
|
||||
border: isTalking ? '2px solid #23a559' : '2px solid transparent',
|
||||
boxShadow: isTalking ? '0 0 4px #23a559' : 'none',
|
||||
transition: 'all 0.1s ease-in-out',
|
||||
flexShrink: 0
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--text-muted)",
|
||||
height: "24px",
|
||||
position: "relative",
|
||||
padding: "2px 4px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
{getUsername(vs.identity, users).substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="member-name" style={{ color: isTalking ? 'white' : 'inherit' }}>{getUsername(vs.identity, users)}</span>
|
||||
{isSharing && (
|
||||
<span style={{
|
||||
backgroundColor: '#f23f43',
|
||||
color: 'white',
|
||||
fontSize: '0.6rem',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 'bold',
|
||||
marginLeft: '4px',
|
||||
flexShrink: 0
|
||||
}}>LIVE</span>
|
||||
)}
|
||||
<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>
|
||||
<div
|
||||
className="avatar"
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
fontSize: "0.5rem",
|
||||
backgroundColor: "var(--brand)",
|
||||
border: isTalking
|
||||
? "2px solid #23a559"
|
||||
: "2px solid transparent",
|
||||
boxShadow: isTalking ? "0 0 4px #23a559" : "none",
|
||||
transition: "all 0.1s ease-in-out",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getUsername(vs.identity, users)
|
||||
.substring(0, 2)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
className="member-name"
|
||||
style={{ color: isTalking ? "white" : "inherit" }}
|
||||
>
|
||||
{getUsername(vs.identity, users)}
|
||||
</span>
|
||||
{isSharing && (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: "#f23f43",
|
||||
color: "white",
|
||||
fontSize: "0.6rem",
|
||||
padding: "1px 4px",
|
||||
borderRadius: "3px",
|
||||
fontWeight: "bold",
|
||||
marginLeft: "4px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
<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 && (
|
||||
<ConnectionPopover
|
||||
stats={peerStats.get(peerIdHex)}
|
||||
status={isMe ? 'connected' : (status || 'connecting')}
|
||||
name={getUsername(vs.identity, users)}
|
||||
isMe={isMe}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hoveredPeer === peerIdHex && (
|
||||
<ConnectionPopover
|
||||
stats={peerStats.get(peerIdHex)}
|
||||
status={isMe ? "connected" : status || "connecting"}
|
||||
name={getUsername(vs.identity, users)}
|
||||
isMe={isMe}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -258,7 +382,7 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
{showCreateChannelModal && (
|
||||
<div className="modal-overlay">
|
||||
<form className="modal-content" onSubmit={handleCreateChannel}>
|
||||
<h2>Create {isVoiceChannel ? 'Voice' : 'Text'} Channel</h2>
|
||||
<h2>Create {isVoiceChannel ? "Voice" : "Text"} Channel</h2>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="channel-name"
|
||||
@@ -266,8 +390,19 @@ export const ChannelList: React.FC<ChannelListProps> = ({
|
||||
onChange={(e) => setNewChannelName(e.target.value)}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={() => setShowCreateChannelModal(false)}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={!isFullyAuthenticated}>Create</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateChannelModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!isFullyAuthenticated}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// src/chat/components/MemberList.tsx
|
||||
import React, { useMemo } from 'react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import type * as Types from '../../module_bindings/types';
|
||||
import { tables } from '../../module_bindings';
|
||||
import React, { useMemo } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import type * as Types from "../../module_bindings/types";
|
||||
import { tables } from "../../module_bindings";
|
||||
|
||||
// Helper function (extracted from App.tsx)
|
||||
const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]) => {
|
||||
const getUsername = (
|
||||
userIdentity: Identity | null,
|
||||
users: readonly Types.User[],
|
||||
) => {
|
||||
if (!userIdentity) return "Unknown";
|
||||
const user = users.find(u => u.identity?.isEqual(userIdentity));
|
||||
const user = users.find((u) => u.identity?.isEqual(userIdentity));
|
||||
return user?.name || userIdentity.toHexString().substring(0, 8);
|
||||
};
|
||||
|
||||
@@ -21,54 +24,89 @@ interface MemberListProps {
|
||||
connectedVoiceChannel: Types.Channel | undefined;
|
||||
}
|
||||
|
||||
function MemberList({ activeServerMembers, users, identity, activeServer, voiceStates, currentVoiceState, connectedVoiceChannel }: MemberListProps) {
|
||||
|
||||
function MemberList({
|
||||
activeServerMembers,
|
||||
users,
|
||||
identity,
|
||||
activeServer,
|
||||
voiceStates,
|
||||
currentVoiceState,
|
||||
connectedVoiceChannel,
|
||||
}: MemberListProps) {
|
||||
// Categorize members into Online and Offline
|
||||
const onlineMembers = useMemo(() =>
|
||||
activeServerMembers.filter(m => m.online),
|
||||
[activeServerMembers]
|
||||
const onlineMembers = useMemo(
|
||||
() => activeServerMembers.filter((m) => m.online),
|
||||
[activeServerMembers],
|
||||
);
|
||||
const offlineMembers = useMemo(() =>
|
||||
activeServerMembers.filter(m => !m.online),
|
||||
[activeServerMembers]
|
||||
const offlineMembers = useMemo(
|
||||
() => activeServerMembers.filter((m) => !m.online),
|
||||
[activeServerMembers],
|
||||
);
|
||||
|
||||
const renderMember = (user: Types.User, isOffline: boolean = false) => {
|
||||
const userVoiceState = voiceStates.find(vs => vs.identity.isEqual(user.identity));
|
||||
const userVoiceState = voiceStates.find((vs) =>
|
||||
vs.identity.isEqual(user.identity),
|
||||
);
|
||||
const isTalking = user.talking || false;
|
||||
const isSharing = userVoiceState?.isSharingScreen || false;
|
||||
const isMe = identity?.isEqual(user.identity);
|
||||
|
||||
return (
|
||||
<div key={user.identity.toHexString()} className="member-item" style={{ opacity: isOffline ? 0.5 : 1 }}>
|
||||
<div
|
||||
key={user.identity.toHexString()}
|
||||
className="member-item"
|
||||
style={{ opacity: isOffline ? 0.5 : 1 }}
|
||||
>
|
||||
<div
|
||||
className="avatar small"
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
fontSize: '0.7rem',
|
||||
backgroundColor: 'var(--background-tertiary)',
|
||||
border: isTalking && !isOffline ? '2px solid #23a559' : '2px solid transparent',
|
||||
boxShadow: isTalking && !isOffline ? '0 0 4px #23a559' : 'none',
|
||||
transition: 'all 0.1s ease-in-out'
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
fontSize: "0.7rem",
|
||||
backgroundColor: "var(--background-tertiary)",
|
||||
border:
|
||||
isTalking && !isOffline
|
||||
? "2px solid #23a559"
|
||||
: "2px solid transparent",
|
||||
boxShadow: isTalking && !isOffline ? "0 0 4px #23a559" : "none",
|
||||
transition: "all 0.1s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{(user.name || user.identity.toHexString()).substring(0, 2).toUpperCase()}
|
||||
{(user.name || user.identity.toHexString())
|
||||
.substring(0, 2)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<span className="member-name" style={{ color: isTalking && !isOffline ? 'white' : 'inherit' }}>
|
||||
<span
|
||||
className="member-name"
|
||||
style={{ color: isTalking && !isOffline ? "white" : "inherit" }}
|
||||
>
|
||||
{user.name || user.identity.toHexString().substring(0, 8)}
|
||||
{isMe && <span style={{ color: 'var(--text-muted)', fontSize: '0.7rem', marginLeft: '4px' }}>(You)</span>}
|
||||
{isMe && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "0.7rem",
|
||||
marginLeft: "4px",
|
||||
}}
|
||||
>
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isSharing && !isOffline && (
|
||||
<span style={{
|
||||
backgroundColor: '#f23f43',
|
||||
color: 'white',
|
||||
fontSize: '0.6rem',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 'auto'
|
||||
}}>LIVE</span>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: "#f23f43",
|
||||
color: "white",
|
||||
fontSize: "0.6rem",
|
||||
padding: "1px 4px",
|
||||
borderRadius: "3px",
|
||||
fontWeight: "bold",
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -79,19 +117,33 @@ function MemberList({ activeServerMembers, users, identity, activeServer, voiceS
|
||||
<div className="member-list">
|
||||
{onlineMembers.length > 0 && (
|
||||
<>
|
||||
<div style={{padding: '0 8px 8px 8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>
|
||||
<div
|
||||
style={{
|
||||
padding: "0 8px 8px 8px",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "bold",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
ONLINE — {onlineMembers.length}
|
||||
</div>
|
||||
{onlineMembers.map(user => renderMember(user))}
|
||||
{onlineMembers.map((user) => renderMember(user))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{offlineMembers.length > 0 && (
|
||||
<>
|
||||
<div style={{padding: '16px 8px 8px 8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)'}}>
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 8px 8px 8px",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "bold",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
OFFLINE — {offlineMembers.length}
|
||||
</div>
|
||||
{offlineMembers.map(user => renderMember(user, true))}
|
||||
{offlineMembers.map((user) => renderMember(user, true))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/chat/components/MessageInput.tsx
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface MessageInputProps {
|
||||
activeChannelId: bigint | null;
|
||||
@@ -8,23 +8,36 @@ interface MessageInputProps {
|
||||
sendMessageReducer: (args: any) => void;
|
||||
}
|
||||
|
||||
function MessageInput({ activeChannelId, activeThreadId, isFullyAuthenticated, sendMessageReducer }: MessageInputProps) {
|
||||
const [messageText, setMessageText] = useState('');
|
||||
function MessageInput({
|
||||
activeChannelId,
|
||||
activeThreadId,
|
||||
isFullyAuthenticated,
|
||||
sendMessageReducer,
|
||||
}: MessageInputProps) {
|
||||
const [messageText, setMessageText] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!messageText.trim() || !activeChannelId) return;
|
||||
|
||||
// Call the sendMessage reducer
|
||||
sendMessageReducer({ text: messageText, channelId: activeChannelId, threadId: activeThreadId });
|
||||
setMessageText('');
|
||||
sendMessageReducer({
|
||||
text: messageText,
|
||||
channelId: activeChannelId,
|
||||
threadId: activeThreadId,
|
||||
});
|
||||
setMessageText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-input-container">
|
||||
<form className="chat-input" onSubmit={handleSubmit}>
|
||||
<input
|
||||
placeholder={isFullyAuthenticated ? `Message ${activeThreadId ? 'in thread...' : '#channel'}` : "Log in to chat"}
|
||||
placeholder={
|
||||
isFullyAuthenticated
|
||||
? `Message ${activeThreadId ? "in thread..." : "#channel"}`
|
||||
: "Log in to chat"
|
||||
}
|
||||
disabled={!isFullyAuthenticated || !activeChannelId}
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
// src/chat/components/MessageList.tsx
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import type * as Types from '../../module_bindings/types';
|
||||
import { useTable } from 'spacetimedb/react';
|
||||
import { tables } from '../../module_bindings';
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import type * as Types from "../../module_bindings/types";
|
||||
import { useTable } from "spacetimedb/react";
|
||||
import { tables } from "../../module_bindings";
|
||||
|
||||
import RichText from './RichText';
|
||||
import RichText from "./RichText";
|
||||
|
||||
// Helper function (extracted from App.tsx)
|
||||
const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]) => {
|
||||
const getUsername = (
|
||||
userIdentity: Identity | null,
|
||||
users: readonly Types.User[],
|
||||
) => {
|
||||
if (!userIdentity) return "Unknown";
|
||||
const user = users.find(u => u.identity?.isEqual(userIdentity));
|
||||
const user = users.find((u) => u.identity?.isEqual(userIdentity));
|
||||
return user?.name || userIdentity.toHexString().substring(0, 8);
|
||||
};
|
||||
|
||||
const formatTime = (ts: any) => {
|
||||
const date = new Date(Number(ts.microsSinceUnixEpoch / 1000n));
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -29,12 +32,20 @@ interface MessageListProps {
|
||||
isFullyAuthenticated: boolean;
|
||||
}
|
||||
|
||||
function MessageList({ messages, activeThreadId, setActiveThreadId, users, identity, handleStartThread, isFullyAuthenticated }: MessageListProps) {
|
||||
function MessageList({
|
||||
messages,
|
||||
activeThreadId,
|
||||
setActiveThreadId,
|
||||
users,
|
||||
identity,
|
||||
handleStartThread,
|
||||
isFullyAuthenticated,
|
||||
}: MessageListProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Fetch threads to display thread links
|
||||
@@ -42,13 +53,25 @@ function MessageList({ messages, activeThreadId, setActiveThreadId, users, ident
|
||||
|
||||
return (
|
||||
<div className="message-list">
|
||||
{messages.map(msg => {
|
||||
{messages.map((msg) => {
|
||||
const msgUsername = getUsername(msg.sender, users);
|
||||
const existingThread = allThreads.find(t => t.parentMessageId === msg.id);
|
||||
const existingThread = allThreads.find(
|
||||
(t) => t.parentMessageId === msg.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={msg.id.toString()} className="message-item">
|
||||
<div className="message-avatar" style={{fontSize: '0.9rem', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 'bold'}}>
|
||||
<div
|
||||
className="message-avatar"
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{msgUsername.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
@@ -61,14 +84,14 @@ function MessageList({ messages, activeThreadId, setActiveThreadId, users, ident
|
||||
onClick={() => handleStartThread(msg)}
|
||||
title="Start Thread"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
opacity: 0.6,
|
||||
marginLeft: '8px',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px'
|
||||
marginLeft: "8px",
|
||||
padding: "2px 4px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
💬
|
||||
@@ -84,16 +107,16 @@ function MessageList({ messages, activeThreadId, setActiveThreadId, users, ident
|
||||
className="thread-link"
|
||||
onClick={() => setActiveThreadId(existingThread.id)}
|
||||
style={{
|
||||
marginTop: '4px',
|
||||
paddingLeft: '12px',
|
||||
borderLeft: '2px solid var(--background-accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '0.85rem'
|
||||
marginTop: "4px",
|
||||
paddingLeft: "12px",
|
||||
borderLeft: "2px solid var(--background-accent)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.8rem' }}>↳</span>
|
||||
<span style={{ fontSize: "0.8rem" }}>↳</span>
|
||||
View Thread ({existingThread.name})
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
interface RichTextProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const isImageUrl = (url: string) => {
|
||||
return (url.match(/\.(jpeg|jpg|gif|png|webp|svg)$/i) != null);
|
||||
return url.match(/\.(jpeg|jpg|gif|png|webp|svg)$/i) != null;
|
||||
};
|
||||
|
||||
const RichText: React.FC<RichTextProps> = ({ text }) => {
|
||||
@@ -18,7 +18,12 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
|
||||
if (part.match(urlRegex)) {
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<a href={part} target="_blank" rel="noopener noreferrer" className="url-link">
|
||||
<a
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="url-link"
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
{isImageUrl(part) && (
|
||||
@@ -29,19 +34,23 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
|
||||
className="message-image"
|
||||
onLoad={(e) => {
|
||||
// Trigger scroll to bottom when image loads by finding closest scrollable list
|
||||
const list = e.currentTarget.closest('.message-list');
|
||||
const list = e.currentTarget.closest(".message-list");
|
||||
if (list) {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
}}
|
||||
onClick={() => window.open(part, '_blank')}
|
||||
onClick={() => window.open(part, "_blank")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return <span key={i} style={{ whiteSpace: 'pre-wrap' }}>{part}</span>;
|
||||
return (
|
||||
<span key={i} style={{ whiteSpace: "pre-wrap" }}>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import type * as Types from '../../module_bindings/types';
|
||||
import React, { useState, useMemo } from "react";
|
||||
import type * as Types from "../../module_bindings/types";
|
||||
|
||||
interface ServerDiscoveryProps {
|
||||
availableServers: readonly Types.Server[];
|
||||
@@ -8,21 +8,43 @@ interface ServerDiscoveryProps {
|
||||
isFullyAuthenticated: boolean;
|
||||
}
|
||||
|
||||
function ServerDiscovery({ availableServers, handleJoinServer, onClose, isFullyAuthenticated }: ServerDiscoveryProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
function ServerDiscovery({
|
||||
availableServers,
|
||||
handleJoinServer,
|
||||
onClose,
|
||||
isFullyAuthenticated,
|
||||
}: ServerDiscoveryProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filteredServers = useMemo(() => {
|
||||
return availableServers.filter(s =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
return availableServers.filter((s) =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}, [availableServers, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content" style={{ width: '500px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{
|
||||
width: "500px",
|
||||
maxHeight: "80vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0 }}>Discover Servers</h2>
|
||||
<button type="button" className="close-btn" onClick={onClose}>×</button>
|
||||
<button type="button" className="close-btn" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -30,30 +52,49 @@ function ServerDiscovery({ availableServers, handleJoinServer, onClose, isFullyA
|
||||
placeholder="Search for servers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ marginBottom: '16px' }}
|
||||
style={{ marginBottom: "16px" }}
|
||||
/>
|
||||
|
||||
<div style={{ overflowY: 'auto', flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{filteredServers.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-muted)' }}>No servers found.</p>
|
||||
<p style={{ textAlign: "center", color: "var(--text-muted)" }}>
|
||||
No servers found.
|
||||
</p>
|
||||
) : (
|
||||
filteredServers.map(server => (
|
||||
filteredServers.map((server) => (
|
||||
<div
|
||||
key={server.id.toString()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px',
|
||||
backgroundColor: 'var(--background-secondary)',
|
||||
borderRadius: '8px'
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "12px",
|
||||
backgroundColor: "var(--background-secondary)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div className="server-icon" style={{ width: '40px', height: '40px', fontSize: '0.9rem' }}>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "12px" }}
|
||||
>
|
||||
<div
|
||||
className="server-icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{server.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span style={{ fontWeight: 'bold' }}>{server.name}</span>
|
||||
<span style={{ fontWeight: "bold" }}>{server.name}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// src/chat/components/ServerList.tsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import type * as Types from '../../module_bindings/types';
|
||||
import { useTable, useReducer } from 'spacetimedb/react'; // Assuming useTable and useReducer are available
|
||||
import { tables, reducers } from '../../module_bindings';
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import type * as Types from "../../module_bindings/types";
|
||||
import { useTable, useReducer } from "spacetimedb/react"; // Assuming useTable and useReducer are available
|
||||
import { tables, reducers } from "../../module_bindings";
|
||||
|
||||
// Helper function (extracted from App.tsx)
|
||||
const getUsername = (userIdentity: Identity | null, users: Types.User[]) => {
|
||||
if (!userIdentity) return "Unknown";
|
||||
const user = users.find(u => u.identity?.isEqual(userIdentity));
|
||||
const user = users.find((u) => u.identity?.isEqual(userIdentity));
|
||||
return user?.name || userIdentity.toHexString().substring(0, 8);
|
||||
};
|
||||
|
||||
@@ -30,17 +30,26 @@ interface ServerListProps {
|
||||
}
|
||||
|
||||
function ServerList({
|
||||
joinedServers, activeServerId, setActiveServerId, isFullyAuthenticated, identity, users,
|
||||
showCreateServerModal, setShowCreateServerModal, newServerName, setNewServerName, handleCreateServer,
|
||||
setShowDiscoveryModal, handleLeaveServer
|
||||
joinedServers,
|
||||
activeServerId,
|
||||
setActiveServerId,
|
||||
isFullyAuthenticated,
|
||||
identity,
|
||||
users,
|
||||
showCreateServerModal,
|
||||
setShowCreateServerModal,
|
||||
newServerName,
|
||||
setNewServerName,
|
||||
handleCreateServer,
|
||||
setShowDiscoveryModal,
|
||||
handleLeaveServer,
|
||||
}: ServerListProps) {
|
||||
|
||||
return (
|
||||
<div className="server-list">
|
||||
{joinedServers.map(server => (
|
||||
<div key={server.id.toString()} style={{ position: 'relative' }}>
|
||||
{joinedServers.map((server) => (
|
||||
<div key={server.id.toString()} style={{ position: "relative" }}>
|
||||
<div
|
||||
className={`server-icon ${activeServerId === server.id ? 'active' : ''}`}
|
||||
className={`server-icon ${activeServerId === server.id ? "active" : ""}`}
|
||||
onClick={() => setActiveServerId(server.id)}
|
||||
title={server.name}
|
||||
>
|
||||
@@ -48,8 +57,25 @@ function ServerList({
|
||||
</div>
|
||||
{activeServerId === server.id && (
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); handleLeaveServer(server.id); }}
|
||||
style={{ position: 'absolute', top: -5, right: -5, backgroundColor: '#da373c', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, cursor: 'pointer', border: '2px solid var(--background-tertiary)' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLeaveServer(server.id);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -5,
|
||||
right: -5,
|
||||
backgroundColor: "#da373c",
|
||||
borderRadius: "50%",
|
||||
width: 16,
|
||||
height: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
cursor: "pointer",
|
||||
border: "2px solid var(--background-tertiary)",
|
||||
}}
|
||||
title="Leave Server"
|
||||
>
|
||||
×
|
||||
@@ -57,8 +83,22 @@ function ServerList({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="server-icon" onClick={() => setShowCreateServerModal(true)} style={{ cursor: isFullyAuthenticated ? 'pointer' : 'not-allowed' }} title="Create Server">+</div>
|
||||
<div className="server-icon" onClick={() => setShowDiscoveryModal(true)} style={{ color: '#23a559' }} title="Discover Servers">🔍</div>
|
||||
<div
|
||||
className="server-icon"
|
||||
onClick={() => setShowCreateServerModal(true)}
|
||||
style={{ cursor: isFullyAuthenticated ? "pointer" : "not-allowed" }}
|
||||
title="Create Server"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
<div
|
||||
className="server-icon"
|
||||
onClick={() => setShowDiscoveryModal(true)}
|
||||
style={{ color: "#23a559" }}
|
||||
title="Discover Servers"
|
||||
>
|
||||
🔍
|
||||
</div>
|
||||
|
||||
{/* Create Server Modal */}
|
||||
{showCreateServerModal && (
|
||||
@@ -72,8 +112,20 @@ function ServerList({
|
||||
onChange={(e) => setNewServerName(e.target.value)}
|
||||
/>
|
||||
<div className="modal-buttons">
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowCreateServerModal(false)}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={!isFullyAuthenticated}>Create</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setShowCreateServerModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!isFullyAuthenticated}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useReducer, useSpacetimeDB, useTable } from 'spacetimedb/react';
|
||||
import { reducers, tables } from '../../module_bindings';
|
||||
import React, { useState } from "react";
|
||||
import { useReducer, useSpacetimeDB, useTable } from "spacetimedb/react";
|
||||
import { reducers, tables } from "../../module_bindings";
|
||||
|
||||
interface SettingsPanelProps {
|
||||
onClose: () => void;
|
||||
@@ -9,9 +9,9 @@ interface SettingsPanelProps {
|
||||
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
|
||||
const { identity } = useSpacetimeDB();
|
||||
const [users] = useTable(tables.user);
|
||||
const currentUser = users.find(u => u.identity.isEqual(identity!));
|
||||
const currentUser = users.find((u) => u.identity.isEqual(identity!));
|
||||
|
||||
const [name, setNameInput] = useState(currentUser?.name || '');
|
||||
const [name, setNameInput] = useState(currentUser?.name || "");
|
||||
const setNameReducer = useReducer(reducers.setName);
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -23,24 +23,41 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>User Settings</h2>
|
||||
<div className="settings-section">
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)', textTransform: 'uppercase' }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "bold",
|
||||
color: "var(--text-muted)",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setNameInput(e.target.value)}
|
||||
onChange={(e) => setNameInput(e.target.value)}
|
||||
placeholder="Enter your display name"
|
||||
autoFocus
|
||||
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||
style={{ width: "100%", boxSizing: "border-box" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="icon-btn" style={{ padding: '8px 16px', fontSize: '0.9rem' }} onClick={onClose}>Cancel</button>
|
||||
<button className="btn-primary" onClick={handleSave}>Save Changes</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ padding: "8px 16px", fontSize: "0.9rem" }}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleSave}>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/chat/components/ThreadMessageInput.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { tables, reducers } from '../../module_bindings';
|
||||
import React, { useState } from "react";
|
||||
import { tables, reducers } from "../../module_bindings";
|
||||
|
||||
interface ThreadMessageInputProps {
|
||||
activeChannelId: bigint | null; // Still needed by sendMessage reducer
|
||||
@@ -9,25 +9,39 @@ interface ThreadMessageInputProps {
|
||||
sendMessageReducer: (args: any) => void;
|
||||
}
|
||||
|
||||
function ThreadMessageInput({ activeChannelId, activeThreadId, isFullyAuthenticated, sendMessageReducer }: ThreadMessageInputProps) {
|
||||
const [threadMessageText, setThreadMessageText] = useState('');
|
||||
function ThreadMessageInput({
|
||||
activeChannelId,
|
||||
activeThreadId,
|
||||
isFullyAuthenticated,
|
||||
sendMessageReducer,
|
||||
}: ThreadMessageInputProps) {
|
||||
const [threadMessageText, setThreadMessageText] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!threadMessageText.trim() || !activeChannelId || !activeThreadId) return;
|
||||
if (!threadMessageText.trim() || !activeChannelId || !activeThreadId)
|
||||
return;
|
||||
|
||||
sendMessageReducer({ text: threadMessageText, channelId: activeChannelId, threadId: activeThreadId });
|
||||
setThreadMessageText('');
|
||||
sendMessageReducer({
|
||||
text: threadMessageText,
|
||||
channelId: activeChannelId,
|
||||
threadId: activeThreadId,
|
||||
});
|
||||
setThreadMessageText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-input-container" style={{padding: '8px'}}>
|
||||
<div className="chat-input-container" style={{ padding: "8px" }}>
|
||||
<form className="chat-input-wrapper" onSubmit={handleSubmit}>
|
||||
<input
|
||||
className="chat-input"
|
||||
style={{fontSize: '0.85rem'}}
|
||||
placeholder={isFullyAuthenticated ? "Reply in thread..." : "Log in to chat"}
|
||||
disabled={!isFullyAuthenticated || !activeChannelId || !activeThreadId}
|
||||
style={{ fontSize: "0.85rem" }}
|
||||
placeholder={
|
||||
isFullyAuthenticated ? "Reply in thread..." : "Log in to chat"
|
||||
}
|
||||
disabled={
|
||||
!isFullyAuthenticated || !activeChannelId || !activeThreadId
|
||||
}
|
||||
value={threadMessageText}
|
||||
onChange={(e) => setThreadMessageText(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// src/chat/components/ThreadMessageList.tsx
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import type * as Types from '../../module_bindings/types';
|
||||
import RichText from './RichText';
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import type * as Types from "../../module_bindings/types";
|
||||
import RichText from "./RichText";
|
||||
|
||||
// Helper function (extracted from App.tsx)
|
||||
const getUsername = (userIdentity: Identity | null, users: readonly Types.User[]) => {
|
||||
const getUsername = (
|
||||
userIdentity: Identity | null,
|
||||
users: readonly Types.User[],
|
||||
) => {
|
||||
if (!userIdentity) return "Unknown";
|
||||
const user = users.find(u => u.identity?.isEqual(userIdentity));
|
||||
const user = users.find((u) => u.identity?.isEqual(userIdentity));
|
||||
return user?.name || userIdentity.toHexString().substring(0, 8);
|
||||
};
|
||||
|
||||
@@ -17,29 +20,54 @@ interface ThreadMessageListProps {
|
||||
identity: Identity | null;
|
||||
}
|
||||
|
||||
function ThreadMessageList({ threadMessages, users, identity }: ThreadMessageListProps) {
|
||||
function ThreadMessageList({
|
||||
threadMessages,
|
||||
users,
|
||||
identity,
|
||||
}: ThreadMessageListProps) {
|
||||
const threadMessagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
threadMessagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
threadMessagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [threadMessages]);
|
||||
|
||||
return (
|
||||
<div className="message-list thread-messages-list" style={{ padding: '8px' }}>
|
||||
{threadMessages.map(msg => {
|
||||
<div
|
||||
className="message-list thread-messages-list"
|
||||
style={{ padding: "8px" }}
|
||||
>
|
||||
{threadMessages.map((msg) => {
|
||||
const msgUsername = getUsername(msg.sender, users);
|
||||
|
||||
return (
|
||||
<div key={msg.id.toString()} className="message-item" style={{ padding: '4px 8px', gap: '12px' }}>
|
||||
<div className="message-avatar" style={{ width: '32px', height: '32px', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 'bold' }}>
|
||||
<div
|
||||
key={msg.id.toString()}
|
||||
className="message-item"
|
||||
style={{ padding: "4px 8px", gap: "12px" }}
|
||||
>
|
||||
<div
|
||||
className="message-avatar"
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
fontSize: "0.8rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{msgUsername.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<div className="message-header">
|
||||
<span className="user-name" style={{ fontSize: '0.85rem' }}>{msgUsername}</span>
|
||||
<span className="user-name" style={{ fontSize: "0.85rem" }}>
|
||||
{msgUsername}
|
||||
</span>
|
||||
</div>
|
||||
<div className="message-text" style={{ fontSize: '0.85rem' }}>
|
||||
<div className="message-text" style={{ fontSize: "0.85rem" }}>
|
||||
<RichText text={msg.text} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// src/chat/components/ThreadView.tsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import type * as Types from '../../module_bindings/types';
|
||||
import { useTable, useReducer } from 'spacetimedb/react'; // Assuming useTable and useReducer are available
|
||||
import { tables, reducers } from '../../module_bindings';
|
||||
import ThreadMessageList from './ThreadMessageList';
|
||||
import ThreadMessageInput from './ThreadMessageInput';
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import type * as Types from "../../module_bindings/types";
|
||||
import { useTable, useReducer } from "spacetimedb/react"; // Assuming useTable and useReducer are available
|
||||
import { tables, reducers } from "../../module_bindings";
|
||||
import ThreadMessageList from "./ThreadMessageList";
|
||||
import ThreadMessageInput from "./ThreadMessageInput";
|
||||
|
||||
interface ThreadViewProps {
|
||||
activeThreadId: bigint;
|
||||
@@ -17,23 +17,35 @@ interface ThreadViewProps {
|
||||
identity: Identity | null;
|
||||
}
|
||||
|
||||
function ThreadView({ activeThreadId, setActiveThreadId, activeChannelId, activeServer, isFullyAuthenticated, users, identity }: ThreadViewProps) {
|
||||
const sendMessageReducer = useReducer(useMemo(() => reducers.sendMessage, [])); // Assuming reducers are accessible
|
||||
function ThreadView({
|
||||
activeThreadId,
|
||||
setActiveThreadId,
|
||||
activeChannelId,
|
||||
activeServer,
|
||||
isFullyAuthenticated,
|
||||
users,
|
||||
identity,
|
||||
}: ThreadViewProps) {
|
||||
const sendMessageReducer = useReducer(
|
||||
useMemo(() => reducers.sendMessage, []),
|
||||
); // Assuming reducers are accessible
|
||||
|
||||
// Fetch all threads and messages
|
||||
const [allThreads] = useTable(useMemo(() => tables.thread, []));
|
||||
const [allMessages] = useTable(useMemo(() => tables.message, []));
|
||||
|
||||
const activeThread = useMemo(() =>
|
||||
allThreads.find(t => t.id === activeThreadId),
|
||||
[allThreads, activeThreadId]
|
||||
const activeThread = useMemo(
|
||||
() => allThreads.find((t) => t.id === activeThreadId),
|
||||
[allThreads, activeThreadId],
|
||||
);
|
||||
|
||||
const threadMessages = useMemo(() => {
|
||||
if (!activeThreadId) return [];
|
||||
return allMessages
|
||||
.filter(m => m.threadId === activeThreadId)
|
||||
.sort((a, b) => a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1);
|
||||
.filter((m) => m.threadId === activeThreadId)
|
||||
.sort((a, b) =>
|
||||
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1,
|
||||
);
|
||||
}, [allMessages, activeThreadId]);
|
||||
|
||||
if (!activeThreadId || !activeThread) {
|
||||
@@ -42,15 +54,41 @@ function ThreadView({ activeThreadId, setActiveThreadId, activeChannelId, active
|
||||
|
||||
return (
|
||||
<div className="thread-view">
|
||||
<div className="thread-header" style={{borderBottom: '1px solid var(--background-accent)', padding: '8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: '8px'}}>
|
||||
<span style={{color: 'var(--brand)', cursor: 'pointer', fontSize: '1.2rem'}} onClick={() => setActiveThreadId(null)}>←</span>
|
||||
<span style={{fontWeight: 'bold', fontSize: '0.9rem'}}>{activeThread.name}</span>
|
||||
<div
|
||||
className="thread-header"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--background-accent)",
|
||||
padding: "8px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--brand)",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.2rem",
|
||||
}}
|
||||
onClick={() => setActiveThreadId(null)}
|
||||
>
|
||||
←
|
||||
</span>
|
||||
<span style={{ fontWeight: "bold", fontSize: "0.9rem" }}>
|
||||
{activeThread.name}
|
||||
</span>
|
||||
</div>
|
||||
<button className="close-btn" onClick={() => setActiveThreadId(null)}>×</button>
|
||||
<button className="close-btn" onClick={() => setActiveThreadId(null)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ThreadMessageList threadMessages={threadMessages} users={users} identity={identity} />
|
||||
<ThreadMessageList
|
||||
threadMessages={threadMessages}
|
||||
users={users}
|
||||
identity={identity}
|
||||
/>
|
||||
|
||||
<ThreadMessageInput
|
||||
activeChannelId={activeChannelId}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import { useTable, useSpacetimeDB } from 'spacetimedb/react';
|
||||
import { tables } from '../../module_bindings';
|
||||
import * as Types from '../../module_bindings/types';
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { useTable, useSpacetimeDB } from "spacetimedb/react";
|
||||
import { tables } from "../../module_bindings";
|
||||
import * as Types from "../../module_bindings/types";
|
||||
|
||||
interface VideoGridProps {
|
||||
peers: Map<string, { audio?: HTMLAudioElement; videoStream?: MediaStream }>;
|
||||
@@ -21,7 +21,7 @@ const VideoTile = ({
|
||||
onToggleWatch,
|
||||
isWatching,
|
||||
isSharing,
|
||||
isHero
|
||||
isHero,
|
||||
}: {
|
||||
identity: Identity;
|
||||
stream?: MediaStream;
|
||||
@@ -36,26 +36,31 @@ const VideoTile = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [users] = useTable(tables.user);
|
||||
const [isMuted, setIsMuted] = React.useState(true);
|
||||
const user = users.find(u => u.identity.isEqual(identity));
|
||||
const name = user?.name || user?.username || identity.toHexString().substring(0, 8);
|
||||
const user = users.find((u) => u.identity.isEqual(identity));
|
||||
const name =
|
||||
user?.name || user?.username || identity.toHexString().substring(0, 8);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const shouldShow = isLocal || isWatching;
|
||||
|
||||
console.log(`[VideoTile] ${name}: isLocal=${isLocal}, isWatching=${isWatching}, isSharing=${isSharing}, hasStream=${!!stream}, isMuted=${isMuted}`);
|
||||
console.log(
|
||||
`[VideoTile] ${name}: isLocal=${isLocal}, isWatching=${isWatching}, isSharing=${isSharing}, hasStream=${!!stream}, isMuted=${isMuted}`,
|
||||
);
|
||||
|
||||
if (video && stream && shouldShow) {
|
||||
if (video.srcObject !== stream) {
|
||||
console.log(`[VideoTile] Linking stream to ${name} (${isLocal ? 'local' : 'remote'})`);
|
||||
console.log(
|
||||
`[VideoTile] Linking stream to ${name} (${isLocal ? "local" : "remote"})`,
|
||||
);
|
||||
video.srcObject = stream;
|
||||
}
|
||||
|
||||
// Muted is usually required for autoplay, but if the user has interacted
|
||||
// (e.g. clicked unmute), we can change it.
|
||||
video.muted = isMuted;
|
||||
video.play().catch(err => {
|
||||
if (err.name !== 'AbortError') {
|
||||
video.play().catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
console.warn(`[VideoTile] Play failed for ${name}:`, err);
|
||||
}
|
||||
});
|
||||
@@ -76,7 +81,7 @@ const VideoTile = ({
|
||||
|
||||
const toggleMute = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsMuted(prev => !prev);
|
||||
setIsMuted((prev) => !prev);
|
||||
};
|
||||
|
||||
const showStream = (isLocal || isWatching) && stream;
|
||||
@@ -84,80 +89,104 @@ const VideoTile = ({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`video-tile ${isTalking ? 'talking' : ''} ${isHero ? 'hero' : ''}`}
|
||||
className={`video-tile ${isTalking ? "talking" : ""} ${isHero ? "hero" : ""}`}
|
||||
>
|
||||
{showStream ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted={isMuted}
|
||||
/>
|
||||
<video ref={videoRef} autoPlay playsInline muted={isMuted} />
|
||||
<div className="video-controls">
|
||||
{!isLocal && (
|
||||
<button className="watch-btn active" onClick={(e) => { e.stopPropagation(); onToggleWatch?.(); }} title="Stop Watching">
|
||||
<button
|
||||
className="watch-btn active"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatch?.();
|
||||
}}
|
||||
title="Stop Watching"
|
||||
>
|
||||
Stop Watching
|
||||
</button>
|
||||
)}
|
||||
<button className="fullscreen-btn" onClick={handleFullscreen} title="Toggle Fullscreen">
|
||||
<button
|
||||
className="fullscreen-btn"
|
||||
onClick={handleFullscreen}
|
||||
title="Toggle Fullscreen"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
</div>
|
||||
<div className="tile-actions-right">
|
||||
{!isLocal && stream && (
|
||||
stream.getAudioTracks().length > 0 ? (
|
||||
<button className="mute-tile-btn" onClick={toggleMute} title={isMuted ? "Unmute" : "Mute"}>
|
||||
{isMuted ? '🔈' : '🔊'}
|
||||
{!isLocal &&
|
||||
stream &&
|
||||
(stream.getAudioTracks().length > 0 ? (
|
||||
<button
|
||||
className="mute-tile-btn"
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? "🔈" : "🔊"}
|
||||
</button>
|
||||
) : (
|
||||
<button className="mute-tile-btn disabled" title="Streamer is not sharing audio" style={{ cursor: 'help', opacity: 0.6 }}>
|
||||
<button
|
||||
className="mute-tile-btn disabled"
|
||||
title="Streamer is not sharing audio"
|
||||
style={{ cursor: "help", opacity: 0.6 }}
|
||||
>
|
||||
🔇
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="avatar-placeholder-container">
|
||||
<div className="avatar-placeholder">
|
||||
{name[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="avatar-placeholder">{name[0].toUpperCase()}</div>
|
||||
{!isLocal && isSharing && (
|
||||
<button className="watch-btn" onClick={(e) => { e.stopPropagation(); onToggleWatch?.(); }}>
|
||||
<button
|
||||
className="watch-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatch?.();
|
||||
}}
|
||||
>
|
||||
Watch Stream
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="tile-info">
|
||||
<span className="user-name">{name} {isLocal ? '(You)' : ''}</span>
|
||||
<span className="user-name">
|
||||
{name} {isLocal ? "(You)" : ""}
|
||||
</span>
|
||||
{isSharing && <span className="sharing-badge">LIVE</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const VideoGrid: React.FC<VideoGridProps> = ({
|
||||
peers,
|
||||
localScreenStream,
|
||||
connectedChannelId,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
watching
|
||||
watching,
|
||||
}) => {
|
||||
const { identity: localIdentity } = useSpacetimeDB();
|
||||
const [voiceStates] = useTable(tables.voice_state);
|
||||
const [users] = useTable(tables.user);
|
||||
const [focusedIdentity, setFocusedIdentity] = React.useState<Identity | null>(null);
|
||||
const [focusedIdentity, setFocusedIdentity] = React.useState<Identity | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const participants = voiceStates.filter(vs => vs.channelId === connectedChannelId);
|
||||
const participants = voiceStates.filter(
|
||||
(vs) => vs.channelId === connectedChannelId,
|
||||
);
|
||||
|
||||
const isWatchingPeer = (peerIdHex: string) => {
|
||||
return watching.some(w =>
|
||||
w.watcher.isEqual(localIdentity!) && w.watchee.toHexString() === peerIdHex
|
||||
return watching.some(
|
||||
(w) =>
|
||||
w.watcher.isEqual(localIdentity!) &&
|
||||
w.watchee.toHexString() === peerIdHex,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -170,7 +199,7 @@ export const VideoGrid: React.FC<VideoGridProps> = ({
|
||||
};
|
||||
|
||||
const localSharing = !!localScreenStream;
|
||||
const remoteSharerVs = participants.find(vs => {
|
||||
const remoteSharerVs = participants.find((vs) => {
|
||||
if (vs.identity.isEqual(localIdentity!)) return false;
|
||||
return vs.isSharingScreen;
|
||||
});
|
||||
@@ -185,15 +214,15 @@ export const VideoGrid: React.FC<VideoGridProps> = ({
|
||||
const isLocal = vs.identity.isEqual(localIdentity!);
|
||||
const peerIdHex = vs.identity.toHexString();
|
||||
const peer = peers.get(peerIdHex);
|
||||
const user = users.find(u => u.identity.isEqual(vs.identity));
|
||||
const user = users.find((u) => u.identity.isEqual(vs.identity));
|
||||
const isHero = primarySharerIdentity?.isEqual(vs.identity);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={peerIdHex}
|
||||
className={`video-tile-container ${isHero ? 'is-hero' : 'is-row'}`}
|
||||
className={`video-tile-container ${isHero ? "is-hero" : "is-row"}`}
|
||||
onClick={() => setFocusedIdentity(vs.identity)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<VideoTile
|
||||
identity={vs.identity}
|
||||
@@ -209,23 +238,27 @@ export const VideoGrid: React.FC<VideoGridProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const heroVs = participants.find(vs => vs.identity.isEqual(primarySharerIdentity || Identity.zero()));
|
||||
const rowParticipants = participants.filter(vs => !vs.identity.isEqual(primarySharerIdentity || Identity.zero()));
|
||||
const heroVs = participants.find((vs) =>
|
||||
vs.identity.isEqual(primarySharerIdentity || Identity.zero()),
|
||||
);
|
||||
const rowParticipants = participants.filter(
|
||||
(vs) => !vs.identity.isEqual(primarySharerIdentity || Identity.zero()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`video-grid ${primarySharerIdentity ? 'has-sharer' : ''}`}>
|
||||
<div className={`video-grid ${primarySharerIdentity ? "has-sharer" : ""}`}>
|
||||
<div className="video-grid-content">
|
||||
{primarySharerIdentity ? (
|
||||
<>
|
||||
{heroVs && renderTile(heroVs)}
|
||||
{rowParticipants.length > 0 && (
|
||||
<div className="video-participants-row">
|
||||
{rowParticipants.map(vs => renderTile(vs))}
|
||||
{rowParticipants.map((vs) => renderTile(vs))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
participants.map(vs => renderTile(vs))
|
||||
participants.map((vs) => renderTile(vs))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
// src/chat/index.ts
|
||||
export { default as ChatContainer } from './ChatContainer';
|
||||
export { useChat } from './services/useChat';
|
||||
export { default as ChatContainer } from "./ChatContainer";
|
||||
export { useChat } from "./services/useChat";
|
||||
|
||||
+311
-141
@@ -1,24 +1,30 @@
|
||||
// src/chat/services/useChat.ts
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTable, useReducer, useSpacetimeDB } from 'spacetimedb/react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import * as Types from '../../module_bindings/types';
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import * as Types from "../../module_bindings/types";
|
||||
// Import tables and reducers from module_bindings
|
||||
import { tables, reducers } from '../../module_bindings';
|
||||
import { tables, reducers } from "../../module_bindings";
|
||||
// Import the useAuth hook (assuming it's defined and exported from src/auth/index.ts or similar)
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { TOKEN_KEY } from '../../main.tsx'; // Import TOKEN_KEY for auth status
|
||||
import { TOKEN_KEY } from "../../main.tsx"; // Import TOKEN_KEY for auth status
|
||||
|
||||
// Helper functions (extracted from App.tsx)
|
||||
const getUsername = (userIdentity: Identity | null, users: Types.User[]) => {
|
||||
if (!userIdentity) return "Unknown";
|
||||
const user = users.find(u => u.identity?.isEqual(userIdentity));
|
||||
const user = users.find((u) => u.identity?.isEqual(userIdentity));
|
||||
return user?.name || userIdentity.toHexString().substring(0, 8);
|
||||
};
|
||||
|
||||
const formatTime = (ts: any) => {
|
||||
const date = new Date(Number(ts.microsSinceUnixEpoch / 1000n));
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
// Define interface for the hook's return value
|
||||
@@ -62,9 +68,21 @@ interface ChatState {
|
||||
voiceChannels: readonly Types.Channel[];
|
||||
|
||||
createServerReducer: (params: { name: string }) => void;
|
||||
createChannelReducer: (params: { serverId: bigint, name: string, isVoice: boolean }) => void;
|
||||
createThreadReducer: (params: { name: string, channelId: bigint, parentMessageId: bigint }) => void;
|
||||
sendMessageReducer: (params: { channelId: bigint, text: string, threadId: bigint | undefined }) => void;
|
||||
createChannelReducer: (params: {
|
||||
serverId: bigint;
|
||||
name: string;
|
||||
isVoice: boolean;
|
||||
}) => void;
|
||||
createThreadReducer: (params: {
|
||||
name: string;
|
||||
channelId: bigint;
|
||||
parentMessageId: bigint;
|
||||
}) => void;
|
||||
sendMessageReducer: (params: {
|
||||
channelId: bigint;
|
||||
text: string;
|
||||
threadId: bigint | undefined;
|
||||
}) => void;
|
||||
joinVoiceReducer: (params: { channelId: bigint }) => void;
|
||||
leaveVoiceReducer: () => void;
|
||||
setNameReducer: (params: { name: string }) => void;
|
||||
@@ -110,17 +128,17 @@ export function useChat(): ChatState {
|
||||
const [activeServerId, setActiveServerId] = useState<bigint | null>(null);
|
||||
const [activeChannelId, setActiveChannelId] = useState<bigint | null>(null);
|
||||
const [activeThreadId, setActiveThreadId] = useState<bigint | null>(null);
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const [threadMessageText, setThreadMessageText] = useState('');
|
||||
const [messageText, setMessageText] = useState("");
|
||||
const [threadMessageText, setThreadMessageText] = useState("");
|
||||
const [showCreateServerModal, setShowCreateServerModal] = useState(false);
|
||||
const [newServerName, setNewServerName] = useState('');
|
||||
const [newServerName, setNewServerName] = useState("");
|
||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
||||
const [newChannelName, setNewChannelName] = useState('');
|
||||
const [newChannelName, setNewChannelName] = useState("");
|
||||
const [isVoiceChannel, setIsVoiceChannel] = useState(false);
|
||||
const [showSetNameModal, setShowSetNameModal] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newName, setNewName] = useState("");
|
||||
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
||||
const [authError, setAuthError] = useState(''); // Consider if this should be part of auth hook
|
||||
const [authError, setAuthError] = useState(""); // Consider if this should be part of auth hook
|
||||
|
||||
// Fetching data from tables
|
||||
const [servers] = useTable(useMemo(() => tables.server, []));
|
||||
@@ -132,15 +150,25 @@ export function useChat(): ChatState {
|
||||
const [voiceStates] = useTable(useMemo(() => tables.voice_state, []));
|
||||
|
||||
// Reducers
|
||||
const createServerReducer = useReducer(useMemo(() => reducers.createServer, []));
|
||||
const createChannelReducer = useReducer(useMemo(() => reducers.createChannel, []));
|
||||
const createThreadReducer = useReducer(useMemo(() => reducers.createThread, []));
|
||||
const sendMessageReducer = useReducer(useMemo(() => reducers.sendMessage, []));
|
||||
const createServerReducer = useReducer(
|
||||
useMemo(() => reducers.createServer, []),
|
||||
);
|
||||
const createChannelReducer = useReducer(
|
||||
useMemo(() => reducers.createChannel, []),
|
||||
);
|
||||
const createThreadReducer = useReducer(
|
||||
useMemo(() => reducers.createThread, []),
|
||||
);
|
||||
const sendMessageReducer = useReducer(
|
||||
useMemo(() => reducers.sendMessage, []),
|
||||
);
|
||||
const joinVoiceReducer = useReducer(useMemo(() => reducers.joinVoice, []));
|
||||
const leaveVoiceReducer = useReducer(useMemo(() => reducers.leaveVoice, []));
|
||||
const setNameReducer = useReducer(useMemo(() => reducers.setName, []));
|
||||
const joinServerReducer = useReducer(useMemo(() => reducers.joinServer, []));
|
||||
const leaveServerReducer = useReducer(useMemo(() => reducers.leaveServer, []));
|
||||
const leaveServerReducer = useReducer(
|
||||
useMemo(() => reducers.leaveServer, []),
|
||||
);
|
||||
|
||||
// Get current identity from SpacetimeDB
|
||||
const { identity } = useSpacetimeDB();
|
||||
@@ -149,39 +177,49 @@ export function useChat(): ChatState {
|
||||
const isFullyAuthenticated = useMemo(() => {
|
||||
// If we have an identity, we are at least partially authenticated (guest)
|
||||
// but the app logic seems to want OIDC or username/password for "full" auth
|
||||
const user = users.find(u => u.identity?.isEqual(identity || Identity.zero()));
|
||||
const user = users.find((u) =>
|
||||
u.identity?.isEqual(identity || Identity.zero()),
|
||||
);
|
||||
if (!user) return false;
|
||||
const hasOidc = !!(user.issuer && user.subject);
|
||||
const hasCreds = !!(user.username && user.password);
|
||||
console.log('useChat: isFullyAuthenticated check - hasOidc:', hasOidc, 'hasCreds:', hasCreds);
|
||||
console.log(
|
||||
"useChat: isFullyAuthenticated check - hasOidc:",
|
||||
hasOidc,
|
||||
"hasCreds:",
|
||||
hasCreds,
|
||||
);
|
||||
return hasOidc || hasCreds;
|
||||
}, [users, identity]);
|
||||
|
||||
// Logging fetched data and auth status
|
||||
console.log('useChat: servers:', servers?.length);
|
||||
console.log('useChat: channels:', channels?.length);
|
||||
console.log('useChat: users:', users?.length, 'isUsersReady:', isUsersReady);
|
||||
console.log('useChat: messages:', allMessages?.length);
|
||||
console.log('useChat: threads:', allThreads?.length);
|
||||
console.log('useChat: voiceStates:', voiceStates?.length);
|
||||
console.log('useChat: auth.isAuthenticated:', auth.isAuthenticated);
|
||||
console.log('useChat: auth.user:', auth.user);
|
||||
console.log('useChat: identity:', identity?.toHexString());
|
||||
console.log('useChat: isFullyAuthenticated:', isFullyAuthenticated);
|
||||
|
||||
console.log("useChat: servers:", servers?.length);
|
||||
console.log("useChat: channels:", channels?.length);
|
||||
console.log("useChat: users:", users?.length, "isUsersReady:", isUsersReady);
|
||||
console.log("useChat: messages:", allMessages?.length);
|
||||
console.log("useChat: threads:", allThreads?.length);
|
||||
console.log("useChat: voiceStates:", voiceStates?.length);
|
||||
console.log("useChat: auth.isAuthenticated:", auth.isAuthenticated);
|
||||
console.log("useChat: auth.user:", auth.user);
|
||||
console.log("useChat: identity:", identity?.toHexString());
|
||||
console.log("useChat: isFullyAuthenticated:", isFullyAuthenticated);
|
||||
|
||||
// Initialization logic for active server/channel
|
||||
const joinedServerIds = useMemo(() => {
|
||||
if (!identity) return new Set<bigint>();
|
||||
return new Set(serverMembers.filter(m => m.identity.isEqual(identity)).map(m => m.serverId));
|
||||
return new Set(
|
||||
serverMembers
|
||||
.filter((m) => m.identity.isEqual(identity))
|
||||
.map((m) => m.serverId),
|
||||
);
|
||||
}, [serverMembers, identity]);
|
||||
|
||||
const joinedServers = useMemo(() => {
|
||||
return servers.filter(s => joinedServerIds.has(s.id));
|
||||
return servers.filter((s) => joinedServerIds.has(s.id));
|
||||
}, [servers, joinedServerIds]);
|
||||
|
||||
const availableServers = useMemo(() => {
|
||||
return servers.filter(s => !joinedServerIds.has(s.id));
|
||||
return servers.filter((s) => !joinedServerIds.has(s.id));
|
||||
}, [servers, joinedServerIds]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -192,9 +230,16 @@ export function useChat(): ChatState {
|
||||
|
||||
useEffect(() => {
|
||||
if (activeServerId) {
|
||||
const serverChannels = channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Text');
|
||||
const serverChannels = channels.filter(
|
||||
(c) => c.serverId === activeServerId && c.kind.tag === "Text",
|
||||
);
|
||||
if (serverChannels.length > 0) {
|
||||
if (!activeChannelId || !channels.some(c => c.id === activeChannelId && c.serverId === activeServerId)) {
|
||||
if (
|
||||
!activeChannelId ||
|
||||
!channels.some(
|
||||
(c) => c.id === activeChannelId && c.serverId === activeServerId,
|
||||
)
|
||||
) {
|
||||
setActiveChannelId(serverChannels[0].id);
|
||||
}
|
||||
} else {
|
||||
@@ -204,121 +249,178 @@ export function useChat(): ChatState {
|
||||
}, [activeServerId, channels, activeChannelId]);
|
||||
|
||||
// Derived Data
|
||||
const activeServer = useMemo(() =>
|
||||
servers.find(s => s.id === activeServerId),
|
||||
[servers, activeServerId]
|
||||
const activeServer = useMemo(
|
||||
() => servers.find((s) => s.id === activeServerId),
|
||||
[servers, activeServerId],
|
||||
);
|
||||
|
||||
const activeChannel = useMemo(() =>
|
||||
channels.find(c => c.id === activeChannelId),
|
||||
[channels, activeChannelId]
|
||||
const activeChannel = useMemo(
|
||||
() => channels.find((c) => c.id === activeChannelId),
|
||||
[channels, activeChannelId],
|
||||
);
|
||||
|
||||
const activeThread = useMemo(() =>
|
||||
allThreads.find(t => t.id === activeThreadId),
|
||||
[allThreads, activeThreadId]
|
||||
const activeThread = useMemo(
|
||||
() => allThreads.find((t) => t.id === activeThreadId),
|
||||
[allThreads, activeThreadId],
|
||||
);
|
||||
|
||||
const isActiveChannelVoice = useMemo(() => activeChannel?.kind.tag === 'Voice', [activeChannel]);
|
||||
const isActiveChannelText = useMemo(() => activeChannel?.kind.tag === 'Text', [activeChannel]);
|
||||
const isActiveChannelVoice = useMemo(
|
||||
() => activeChannel?.kind.tag === "Voice",
|
||||
[activeChannel],
|
||||
);
|
||||
const isActiveChannelText = useMemo(
|
||||
() => activeChannel?.kind.tag === "Text",
|
||||
[activeChannel],
|
||||
);
|
||||
|
||||
const textChannels = useMemo(() => {
|
||||
if (!activeServerId) return [];
|
||||
return channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Text');
|
||||
}, [channels, activeServerId]);
|
||||
if (!activeServerId) return [];
|
||||
return channels.filter(
|
||||
(c) => c.serverId === activeServerId && c.kind.tag === "Text",
|
||||
);
|
||||
}, [channels, activeServerId]);
|
||||
|
||||
const voiceChannels = useMemo(() => {
|
||||
if (!activeServerId) return [];
|
||||
return channels.filter(c => c.serverId === activeServerId && c.kind.tag === 'Voice');
|
||||
}, [channels, activeServerId]);
|
||||
if (!activeServerId) return [];
|
||||
return channels.filter(
|
||||
(c) => c.serverId === activeServerId && c.kind.tag === "Voice",
|
||||
);
|
||||
}, [channels, activeServerId]);
|
||||
|
||||
const channelMessages = useMemo(() => {
|
||||
if (!activeChannelId) return [];
|
||||
return allMessages
|
||||
.filter(m => m.channelId === activeChannelId && m.threadId === undefined)
|
||||
.sort((a, b) => a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1);
|
||||
.filter(
|
||||
(m) => m.channelId === activeChannelId && m.threadId === undefined,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1,
|
||||
);
|
||||
}, [allMessages, activeChannelId]);
|
||||
|
||||
const threadMessages = useMemo(() => {
|
||||
if (!activeThreadId) return [];
|
||||
return allMessages
|
||||
.filter(m => m.threadId === activeThreadId)
|
||||
.sort((a, b) => a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1);
|
||||
.filter((m) => m.threadId === activeThreadId)
|
||||
.sort((a, b) =>
|
||||
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1,
|
||||
);
|
||||
}, [allMessages, activeThreadId]);
|
||||
|
||||
// Updated to use identity from useSpacetimeDB
|
||||
const currentUser = useMemo(() =>
|
||||
users.find(u => u.identity?.isEqual(identity || Identity.zero())),
|
||||
[users, identity]
|
||||
const currentUser = useMemo(
|
||||
() => users.find((u) => u.identity?.isEqual(identity || Identity.zero())),
|
||||
[users, identity],
|
||||
);
|
||||
|
||||
// Updated to use identity from useSpacetimeDB
|
||||
const currentVoiceState = useMemo(() =>
|
||||
voiceStates.find(vs => vs.identity?.isEqual(identity || Identity.zero())),
|
||||
[voiceStates, identity]
|
||||
const currentVoiceState = useMemo(
|
||||
() =>
|
||||
voiceStates.find((vs) =>
|
||||
vs.identity?.isEqual(identity || Identity.zero()),
|
||||
),
|
||||
[voiceStates, identity],
|
||||
);
|
||||
|
||||
const connectedVoiceChannel = useMemo(() =>
|
||||
channels.find(c => c.id === currentVoiceState?.channelId),
|
||||
[channels, currentVoiceState]
|
||||
const connectedVoiceChannel = useMemo(
|
||||
() => channels.find((c) => c.id === currentVoiceState?.channelId),
|
||||
[channels, currentVoiceState],
|
||||
);
|
||||
|
||||
// Updated to use identity from useSpacetimeDB
|
||||
const onlineUsers = useMemo(() =>
|
||||
users.filter(u => u.online || (identity?.isEqual(u.identity))),
|
||||
[users, identity]
|
||||
const onlineUsers = useMemo(
|
||||
() => users.filter((u) => u.online || identity?.isEqual(u.identity)),
|
||||
[users, identity],
|
||||
);
|
||||
|
||||
// Check if user has linked OIDC or username/password credentials
|
||||
// (Moved isFullyAuthenticated up earlier in the file already)
|
||||
|
||||
// Event Handlers
|
||||
const handleSendMessage = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!messageText.trim() || !activeChannelId) return;
|
||||
sendMessageReducer({ text: messageText, channelId: activeChannelId, threadId: undefined });
|
||||
setMessageText('');
|
||||
}, [messageText, activeChannelId, sendMessageReducer]);
|
||||
const handleSendMessage = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!messageText.trim() || !activeChannelId) return;
|
||||
sendMessageReducer({
|
||||
text: messageText,
|
||||
channelId: activeChannelId,
|
||||
threadId: undefined,
|
||||
});
|
||||
setMessageText("");
|
||||
},
|
||||
[messageText, activeChannelId, sendMessageReducer],
|
||||
);
|
||||
|
||||
const handleSendThreadMessage = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!threadMessageText.trim() || !activeThreadId || !activeChannelId) return;
|
||||
sendMessageReducer({ text: threadMessageText, channelId: activeChannelId, threadId: activeThreadId });
|
||||
setThreadMessageText('');
|
||||
}, [threadMessageText, activeThreadId, activeChannelId, sendMessageReducer]);
|
||||
const handleSendThreadMessage = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!threadMessageText.trim() || !activeThreadId || !activeChannelId)
|
||||
return;
|
||||
sendMessageReducer({
|
||||
text: threadMessageText,
|
||||
channelId: activeChannelId,
|
||||
threadId: activeThreadId,
|
||||
});
|
||||
setThreadMessageText("");
|
||||
},
|
||||
[threadMessageText, activeThreadId, activeChannelId, sendMessageReducer],
|
||||
);
|
||||
|
||||
const handleCreateServer = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newServerName.trim()) return;
|
||||
createServerReducer({ name: newServerName });
|
||||
setNewServerName('');
|
||||
setShowCreateServerModal(false);
|
||||
}, [newServerName, createServerReducer]);
|
||||
const handleCreateServer = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newServerName.trim()) return;
|
||||
createServerReducer({ name: newServerName });
|
||||
setNewServerName("");
|
||||
setShowCreateServerModal(false);
|
||||
},
|
||||
[newServerName, createServerReducer],
|
||||
);
|
||||
|
||||
const handleCreateChannel = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newChannelName.trim() || !activeServerId) return;
|
||||
createChannelReducer({ name: newChannelName, serverId: activeServerId, isVoice: isVoiceChannel });
|
||||
setNewChannelName('');
|
||||
setIsVoiceChannel(false);
|
||||
setShowCreateChannelModal(false);
|
||||
}, [newChannelName, activeServerId, isVoiceChannel, createChannelReducer]);
|
||||
const handleCreateChannel = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newChannelName.trim() || !activeServerId) return;
|
||||
createChannelReducer({
|
||||
name: newChannelName,
|
||||
serverId: activeServerId,
|
||||
isVoice: isVoiceChannel,
|
||||
});
|
||||
setNewChannelName("");
|
||||
setIsVoiceChannel(false);
|
||||
setShowCreateChannelModal(false);
|
||||
},
|
||||
[newChannelName, activeServerId, isVoiceChannel, createChannelReducer],
|
||||
);
|
||||
|
||||
const handleSetName = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
setNameReducer({ name: newName });
|
||||
setShowSetNameModal(false);
|
||||
}, [newName, setNameReducer]);
|
||||
const handleSetName = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
setNameReducer({ name: newName });
|
||||
setShowSetNameModal(false);
|
||||
},
|
||||
[newName, setNameReducer],
|
||||
);
|
||||
|
||||
const handleStartThread = useCallback((msg: Types.Message) => {
|
||||
const threadName = `Thread on: ${msg.text.substring(0, 20)}...`;
|
||||
createThreadReducer({ name: threadName, channelId: msg.channelId, parentMessageId: msg.id });
|
||||
}, [createThreadReducer]);
|
||||
const handleStartThread = useCallback(
|
||||
(msg: Types.Message) => {
|
||||
const threadName = `Thread on: ${msg.text.substring(0, 20)}...`;
|
||||
createThreadReducer({
|
||||
name: threadName,
|
||||
channelId: msg.channelId,
|
||||
parentMessageId: msg.id,
|
||||
});
|
||||
},
|
||||
[createThreadReducer],
|
||||
);
|
||||
|
||||
const handleJoinVoice = useCallback((channelId: bigint) => {
|
||||
joinVoiceReducer({ channelId });
|
||||
}, [joinVoiceReducer]);
|
||||
const handleJoinVoice = useCallback(
|
||||
(channelId: bigint) => {
|
||||
joinVoiceReducer({ channelId });
|
||||
},
|
||||
[joinVoiceReducer],
|
||||
);
|
||||
|
||||
const handleLeaveVoice = useCallback(() => {
|
||||
if (currentVoiceState) {
|
||||
@@ -326,55 +428,123 @@ export function useChat(): ChatState {
|
||||
}
|
||||
}, [currentVoiceState, leaveVoiceReducer]);
|
||||
|
||||
const handleJoinServer = useCallback((serverId: bigint) => {
|
||||
joinServerReducer({ serverId });
|
||||
setShowDiscoveryModal(false);
|
||||
}, [joinServerReducer]);
|
||||
const handleJoinServer = useCallback(
|
||||
(serverId: bigint) => {
|
||||
joinServerReducer({ serverId });
|
||||
setShowDiscoveryModal(false);
|
||||
},
|
||||
[joinServerReducer],
|
||||
);
|
||||
|
||||
const handleLeaveServer = useCallback((serverId: bigint) => {
|
||||
leaveServerReducer({ serverId });
|
||||
if (activeServerId === serverId) {
|
||||
setActiveServerId(null);
|
||||
}
|
||||
}, [activeServerId, leaveServerReducer]);
|
||||
const handleLeaveServer = useCallback(
|
||||
(serverId: bigint) => {
|
||||
leaveServerReducer({ serverId });
|
||||
if (activeServerId === serverId) {
|
||||
setActiveServerId(null);
|
||||
}
|
||||
},
|
||||
[activeServerId, leaveServerReducer],
|
||||
);
|
||||
|
||||
return {
|
||||
// State variables
|
||||
activeServerId, activeChannelId, activeThreadId, messageText, threadMessageText,
|
||||
showCreateServerModal, newServerName, showCreateChannelModal, newChannelName,
|
||||
isVoiceChannel, showSetNameModal, newName, showDiscoveryModal, authError,
|
||||
activeServerId,
|
||||
activeChannelId,
|
||||
activeThreadId,
|
||||
messageText,
|
||||
threadMessageText,
|
||||
showCreateServerModal,
|
||||
newServerName,
|
||||
showCreateChannelModal,
|
||||
newChannelName,
|
||||
isVoiceChannel,
|
||||
showSetNameModal,
|
||||
newName,
|
||||
showDiscoveryModal,
|
||||
authError,
|
||||
|
||||
// Data fetched from tables
|
||||
servers, joinedServers, availableServers, channels, users, allMessages, allThreads, voiceStates,
|
||||
currentVoiceState, connectedVoiceChannel, onlineUsers,
|
||||
servers,
|
||||
joinedServers,
|
||||
availableServers,
|
||||
channels,
|
||||
users,
|
||||
allMessages,
|
||||
allThreads,
|
||||
voiceStates,
|
||||
currentVoiceState,
|
||||
connectedVoiceChannel,
|
||||
onlineUsers,
|
||||
activeServerMembers: useMemo(() => {
|
||||
if (!activeServerId) return [];
|
||||
const memberIdentities = new Set(serverMembers.filter(m => m.serverId === activeServerId).map(m => m.identity.toHexString()));
|
||||
return users.filter(u => memberIdentities.has(u.identity.toHexString()));
|
||||
const memberIdentities = new Set(
|
||||
serverMembers
|
||||
.filter((m) => m.serverId === activeServerId)
|
||||
.map((m) => m.identity.toHexString()),
|
||||
);
|
||||
return users.filter((u) =>
|
||||
memberIdentities.has(u.identity.toHexString()),
|
||||
);
|
||||
}, [serverMembers, users, activeServerId]),
|
||||
currentUser,
|
||||
activeServer, activeChannel, activeThread, isActiveChannelVoice, isActiveChannelText, channelMessages, threadMessages,
|
||||
textChannels, voiceChannels,
|
||||
activeServer,
|
||||
activeChannel,
|
||||
activeThread,
|
||||
isActiveChannelVoice,
|
||||
isActiveChannelText,
|
||||
channelMessages,
|
||||
threadMessages,
|
||||
textChannels,
|
||||
voiceChannels,
|
||||
|
||||
// Reducers
|
||||
createServerReducer, createChannelReducer, createThreadReducer, sendMessageReducer,
|
||||
joinVoiceReducer, leaveVoiceReducer, setNameReducer, joinServerReducer, leaveServerReducer,
|
||||
createServerReducer,
|
||||
createChannelReducer,
|
||||
createThreadReducer,
|
||||
sendMessageReducer,
|
||||
joinVoiceReducer,
|
||||
leaveVoiceReducer,
|
||||
setNameReducer,
|
||||
joinServerReducer,
|
||||
leaveServerReducer,
|
||||
|
||||
// State setters
|
||||
setActiveServerId, setActiveChannelId, setActiveThreadId, setMessageText,
|
||||
setThreadMessageText, setShowCreateServerModal, setNewServerName, setShowCreateChannelModal,
|
||||
setNewChannelName, setIsVoiceChannel, setShowSetNameModal, setNewName, setShowDiscoveryModal, setAuthError,
|
||||
setActiveServerId,
|
||||
setActiveChannelId,
|
||||
setActiveThreadId,
|
||||
setMessageText,
|
||||
setThreadMessageText,
|
||||
setShowCreateServerModal,
|
||||
setNewServerName,
|
||||
setShowCreateChannelModal,
|
||||
setNewChannelName,
|
||||
setIsVoiceChannel,
|
||||
setShowSetNameModal,
|
||||
setNewName,
|
||||
setShowDiscoveryModal,
|
||||
setAuthError,
|
||||
|
||||
// Event handlers
|
||||
handleSendMessage, handleSendThreadMessage, handleCreateServer, handleCreateChannel,
|
||||
handleStartThread, handleJoinVoice, handleLeaveVoice, handleSetName,
|
||||
handleJoinServer, handleLeaveServer,
|
||||
handleSendMessage,
|
||||
handleSendThreadMessage,
|
||||
handleCreateServer,
|
||||
handleCreateChannel,
|
||||
handleStartThread,
|
||||
handleJoinVoice,
|
||||
handleLeaveVoice,
|
||||
handleSetName,
|
||||
handleJoinServer,
|
||||
handleLeaveServer,
|
||||
|
||||
// Derived status
|
||||
isFullyAuthenticated,
|
||||
|
||||
// Helper functions
|
||||
getUsername: useCallback((userIdentity: Identity | null) => getUsername(userIdentity, users as Types.User[]), [users]),
|
||||
getUsername: useCallback(
|
||||
(userIdentity: Identity | null) =>
|
||||
getUsername(userIdentity, users as Types.User[]),
|
||||
[users],
|
||||
),
|
||||
formatTime: useCallback((ts: any) => formatTime(ts), []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { useTable, useReducer } from "spacetimedb/react";
|
||||
import { tables, reducers } from "../../../module_bindings";
|
||||
import { usePeerManager } from "./usePeerManager";
|
||||
|
||||
export const useChannelAudioWebRTC = (
|
||||
connectedChannelId: bigint | undefined,
|
||||
identity: Identity | null,
|
||||
localStream: MediaStream | null,
|
||||
isDeafened: boolean
|
||||
) => {
|
||||
const [voiceStates] = useTable(tables.voice_state);
|
||||
const [offers] = useTable(tables.voice_sdp_offer);
|
||||
const [answers] = useTable(tables.voice_sdp_answer);
|
||||
const [iceCandidates] = useTable(tables.voice_ice_candidate);
|
||||
|
||||
const sendSdpOffer = useReducer(reducers.sendVoiceSdpOffer);
|
||||
const sendSdpAnswer = useReducer(reducers.sendVoiceSdpAnswer);
|
||||
const sendIceCandidate = useReducer(reducers.sendVoiceIceCandidate);
|
||||
|
||||
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
|
||||
const ignoreOfferRef = useRef<Map<string, boolean>>(new Map());
|
||||
const processedOffersRef = useRef<Set<bigint>>(new Set());
|
||||
const processedAnswersRef = useRef<Set<bigint>>(new Set());
|
||||
const processedCandidatesRef = useRef<Set<bigint>>(new Set());
|
||||
const candidateQueueRef = useRef<Map<string, any[]>>(new Map());
|
||||
|
||||
const connectedChannelIdRef = useRef(connectedChannelId);
|
||||
useEffect(() => { connectedChannelIdRef.current = connectedChannelId; }, [connectedChannelId]);
|
||||
|
||||
const drainCandidateQueue = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
|
||||
const queue = candidateQueueRef.current.get(peerIdHex) || [];
|
||||
if (queue.length === 0 || !pc.remoteDescription) return;
|
||||
console.log(`[WebRTC][voice] Draining ${queue.length} candidates for ${peerIdHex}`);
|
||||
for (const cand of queue) {
|
||||
try { await pc.addIceCandidate(new RTCIceCandidate(cand)); }
|
||||
catch (e) { console.warn(`[WebRTC][voice] Error adding queued ICE for ${peerIdHex}`, e); }
|
||||
}
|
||||
candidateQueueRef.current.set(peerIdHex, []);
|
||||
}, []);
|
||||
|
||||
const onNegotiationNeeded = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
|
||||
const channelId = connectedChannelIdRef.current;
|
||||
if (!channelId || pc.signalingState !== 'stable' || makingOfferRef.current.get(peerIdHex)) {
|
||||
console.log(`[WebRTC][voice] Skipping negotiation for ${peerIdHex}: state=${pc.signalingState}, makingOffer=${makingOfferRef.current.get(peerIdHex)}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
makingOfferRef.current.set(peerIdHex, true);
|
||||
console.log(`[WebRTC][voice] Creating offer for ${peerIdHex}...`);
|
||||
await pc.setLocalDescription();
|
||||
sendSdpOffer({
|
||||
receiver: Identity.fromString(peerIdHex),
|
||||
sdp: JSON.stringify(pc.localDescription),
|
||||
channelId
|
||||
});
|
||||
} catch (e) { console.error(`[WebRTC][voice] Negotiation error for ${peerIdHex}`, e); }
|
||||
finally { makingOfferRef.current.set(peerIdHex, false); }
|
||||
}, [sendSdpOffer]);
|
||||
|
||||
const onIceCandidate = useCallback((peerIdHex: string, candidate: RTCIceCandidate) => {
|
||||
const channelId = connectedChannelIdRef.current;
|
||||
if (channelId) {
|
||||
sendIceCandidate({
|
||||
receiver: Identity.fromString(peerIdHex),
|
||||
candidate: JSON.stringify(candidate),
|
||||
channelId
|
||||
});
|
||||
}
|
||||
}, [sendIceCandidate]);
|
||||
|
||||
const peerManager = usePeerManager(
|
||||
identity,
|
||||
"voice",
|
||||
isDeafened,
|
||||
onNegotiationNeeded,
|
||||
onIceCandidate
|
||||
);
|
||||
|
||||
// Handle Incoming Signaling
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
|
||||
// Offers
|
||||
const myOffers = offers.filter(o => o.receiver.isEqual(identity) && !o.sender.isEqual(identity) && o.channelId === connectedChannelId);
|
||||
(async () => {
|
||||
for (const offerRow of myOffers) {
|
||||
if (processedOffersRef.current.has(offerRow.id)) continue;
|
||||
processedOffersRef.current.add(offerRow.id);
|
||||
|
||||
const peerIdHex = offerRow.sender.toHexString();
|
||||
console.log(`[WebRTC][voice] Received offer from ${peerIdHex}`);
|
||||
const pc = peerManager.createPeerConnection(peerIdHex);
|
||||
if (!pc) continue;
|
||||
|
||||
try {
|
||||
const isPolite = identity.toHexString() < peerIdHex;
|
||||
const makingOffer = makingOfferRef.current.get(peerIdHex) || false;
|
||||
const offerCollision = (pc.signalingState !== "stable") || makingOffer;
|
||||
const ignoreOffer = !isPolite && offerCollision;
|
||||
|
||||
ignoreOfferRef.current.set(peerIdHex, ignoreOffer);
|
||||
if (ignoreOffer) {
|
||||
console.log(`[WebRTC][voice] Ignoring offer collision from ${peerIdHex} (impolite)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (offerCollision) {
|
||||
console.log(`[WebRTC][voice] Handling offer collision from ${peerIdHex} (polite), rolling back...`);
|
||||
await pc.setLocalDescription({ type: "rollback" as RTCSdpType });
|
||||
}
|
||||
|
||||
console.log(`[WebRTC][voice] Setting remote description from ${peerIdHex}`);
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(offerRow.sdp)));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
console.log(`[WebRTC][voice] Sending answer to ${peerIdHex}`);
|
||||
sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId });
|
||||
await drainCandidateQueue(peerIdHex, pc);
|
||||
} catch (e) { console.error(`[WebRTC][voice] Error handling offer from ${peerIdHex}`, e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Answers
|
||||
const myAnswers = answers.filter(a => a.receiver.isEqual(identity) && !a.sender.isEqual(identity) && a.channelId === connectedChannelId);
|
||||
(async () => {
|
||||
for (const answerRow of myAnswers) {
|
||||
if (processedAnswersRef.current.has(answerRow.id)) continue;
|
||||
processedAnswersRef.current.add(answerRow.id);
|
||||
|
||||
const peerIdHex = answerRow.sender.toHexString();
|
||||
const peer = peerManager.getPeer(peerIdHex);
|
||||
if (peer) {
|
||||
try {
|
||||
console.log(`[WebRTC][voice] Received answer from ${peerIdHex}, setting remote description`);
|
||||
await peer.pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(answerRow.sdp)));
|
||||
await drainCandidateQueue(peerIdHex, peer.pc);
|
||||
} catch (e) { console.error(`[WebRTC][voice] Error handling answer from ${peerIdHex}`, e); }
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// ICE Candidates
|
||||
const myCandidates = iceCandidates.filter(c => c.receiver.isEqual(identity) && !c.sender.isEqual(identity) && c.channelId === connectedChannelId);
|
||||
(async () => {
|
||||
for (const candRow of myCandidates) {
|
||||
if (processedCandidatesRef.current.has(candRow.id)) continue;
|
||||
processedCandidatesRef.current.add(candRow.id);
|
||||
|
||||
const peerIdHex = candRow.sender.toHexString();
|
||||
const pc = peerManager.createPeerConnection(peerIdHex);
|
||||
if (!pc) continue;
|
||||
|
||||
try {
|
||||
const candidate = JSON.parse(candRow.candidate);
|
||||
const ignoreOffer = ignoreOfferRef.current.get(peerIdHex) || false;
|
||||
|
||||
if (pc.remoteDescription) {
|
||||
console.log(`[WebRTC][voice] Adding ICE candidate from ${peerIdHex}`);
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} else if (!ignoreOffer) {
|
||||
console.log(`[WebRTC][voice] Queueing ICE candidate from ${peerIdHex}`);
|
||||
const queue = candidateQueueRef.current.get(peerIdHex) || [];
|
||||
queue.push(candidate);
|
||||
candidateQueueRef.current.set(peerIdHex, queue);
|
||||
}
|
||||
} catch (e) { console.error(`[WebRTC][voice] Error handling ICE from ${peerIdHex}`, e); }
|
||||
}
|
||||
})();
|
||||
}, [offers, answers, iceCandidates, connectedChannelId, identity, peerManager, sendSdpAnswer, drainCandidateQueue]);
|
||||
|
||||
// Track Syncing
|
||||
useEffect(() => {
|
||||
const audioTrack = localStream?.getAudioTracks()[0] || null;
|
||||
peerManager.peersRef.current.forEach(async (peer, peerIdHex) => {
|
||||
const transceivers = peer.pc.getTransceivers();
|
||||
if (transceivers[0] && transceivers[0].sender.track !== audioTrack) {
|
||||
try {
|
||||
console.log(`[WebRTC][voice] Syncing audio track for ${peerIdHex}`);
|
||||
await transceivers[0].sender.replaceTrack(audioTrack);
|
||||
if (peer.pc.signalingState === 'stable') onNegotiationNeeded(peerIdHex, peer.pc);
|
||||
} catch (e) { console.error(`[WebRTC][voice] Error syncing track for ${peerIdHex}`, e); }
|
||||
}
|
||||
});
|
||||
}, [localStream, peerManager.peers, onNegotiationNeeded, peerManager.peersRef]);
|
||||
|
||||
// Lifecycle
|
||||
const voicePeersToConnect = useMemo(() => {
|
||||
if (!identity || !connectedChannelId) return new Set<string>();
|
||||
return new Set(voiceStates
|
||||
.filter(vs => vs.channelId === connectedChannelId && !vs.identity.isEqual(identity))
|
||||
.map(vs => vs.identity.toHexString()));
|
||||
}, [voiceStates, identity, connectedChannelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) {
|
||||
console.log(`[WebRTC][voice] Cleaning up connections (channel=${connectedChannelId}, identity=${!!identity})`);
|
||||
peerManager.peersRef.current.forEach((_, id) => peerManager.closePeer(id));
|
||||
processedOffersRef.current.clear();
|
||||
processedAnswersRef.current.clear();
|
||||
processedCandidatesRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
voicePeersToConnect.forEach(id => {
|
||||
if (!peerManager.peersRef.current.has(id)) {
|
||||
console.log(`[WebRTC][voice] Proactively connecting to ${id}`);
|
||||
peerManager.createPeerConnection(id, [localStream?.getAudioTracks()[0] || null]);
|
||||
}
|
||||
});
|
||||
|
||||
peerManager.peersRef.current.forEach((_, id) => {
|
||||
if (!voicePeersToConnect.has(id)) {
|
||||
console.log(`[WebRTC][voice] Peer ${id} no longer in channel, closing`);
|
||||
peerManager.closePeer(id);
|
||||
}
|
||||
});
|
||||
}, [voicePeersToConnect, connectedChannelId, identity, peerManager, localStream]);
|
||||
|
||||
return {
|
||||
peerStatuses: peerManager.peerStatuses,
|
||||
peerStats: peerManager.peerStats,
|
||||
peers: peerManager.peers
|
||||
};
|
||||
};
|
||||
@@ -4,7 +4,8 @@ import { reducers } from "../../../module_bindings";
|
||||
|
||||
export const useLocalMedia = () => {
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [localScreenStream, setLocalScreenStream] = useState<MediaStream | null>(null);
|
||||
const [localScreenStream, setLocalScreenStream] =
|
||||
useState<MediaStream | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDeafened, setIsDeafened] = useState(false);
|
||||
const [isTalking, setIsTalking] = useState(false);
|
||||
@@ -16,14 +17,17 @@ export const useLocalMedia = () => {
|
||||
const setTalking = useReducer(reducers.setTalking);
|
||||
const setSharingScreen = useReducer(reducers.setSharingScreen);
|
||||
|
||||
const toggleMute = useCallback(() => setIsMuted(prev => !prev), []);
|
||||
const toggleDeafen = useCallback(() => setIsDeafened(prev => !prev), []);
|
||||
const toggleMute = useCallback(() => setIsMuted((prev) => !prev), []);
|
||||
const toggleDeafen = useCallback(() => setIsDeafened((prev) => !prev), []);
|
||||
|
||||
const requestMic = useCallback(async () => {
|
||||
if (localStreamRef.current) return;
|
||||
try {
|
||||
console.log("[WebRTC] Requesting mic permission...");
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
video: false,
|
||||
});
|
||||
setLocalStream(stream);
|
||||
localStreamRef.current = stream;
|
||||
} catch (err) {
|
||||
@@ -33,44 +37,56 @@ export const useLocalMedia = () => {
|
||||
|
||||
const releaseMic = useCallback(() => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
localStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
setLocalStream(null);
|
||||
localStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startScreenShare = useCallback(async (onTrackReady: (track: MediaStreamTrack) => void) => {
|
||||
try {
|
||||
console.log("[WebRTC] Requesting screen share...");
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||
setLocalScreenStream(stream);
|
||||
localScreenStreamRef.current = stream;
|
||||
setSharingScreen({ sharing: true });
|
||||
const startScreenShare = useCallback(
|
||||
async (onTrackReady: (track: MediaStreamTrack) => void) => {
|
||||
try {
|
||||
console.log("[WebRTC] Requesting screen share...");
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
});
|
||||
setLocalScreenStream(stream);
|
||||
localScreenStreamRef.current = stream;
|
||||
setSharingScreen({ sharing: true });
|
||||
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
onTrackReady(videoTrack);
|
||||
videoTrack.onended = () => stopScreenShare(() => onTrackReady(null as any));
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
onTrackReady(videoTrack);
|
||||
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) => {
|
||||
if (localScreenStreamRef.current) {
|
||||
localScreenStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
setLocalScreenStream(null);
|
||||
localScreenStreamRef.current = null;
|
||||
setSharingScreen({ sharing: false });
|
||||
onTrackCleared(null);
|
||||
}
|
||||
}, [setSharingScreen]);
|
||||
const stopScreenShare = useCallback(
|
||||
(onTrackCleared: (track: MediaStreamTrack | null) => void) => {
|
||||
if (localScreenStreamRef.current) {
|
||||
localScreenStreamRef.current
|
||||
.getTracks()
|
||||
.forEach((track) => track.stop());
|
||||
setLocalScreenStream(null);
|
||||
localScreenStreamRef.current = null;
|
||||
setSharingScreen({ sharing: false });
|
||||
onTrackCleared(null);
|
||||
}
|
||||
},
|
||||
[setSharingScreen],
|
||||
);
|
||||
|
||||
// Handle Mute/Deafen effect on tracks
|
||||
useEffect(() => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getAudioTracks().forEach(track => {
|
||||
localStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = !isMuted && !isDeafened;
|
||||
});
|
||||
}
|
||||
@@ -140,6 +156,6 @@ export const useLocalMedia = () => {
|
||||
requestMic,
|
||||
releaseMic,
|
||||
localStreamRef,
|
||||
localScreenStreamRef
|
||||
localScreenStreamRef,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { Peer, WebRTCStats } from "./types";
|
||||
|
||||
@@ -8,166 +8,238 @@ const ICE_SERVERS: RTCConfiguration = {
|
||||
|
||||
export const usePeerManager = (
|
||||
identity: Identity | null,
|
||||
isDeafened: boolean,
|
||||
localStreamRef: React.MutableRefObject<MediaStream | null>,
|
||||
localScreenStreamRef: React.MutableRefObject<MediaStream | null>,
|
||||
mediaType: "voice" | "screen",
|
||||
isDeafened: boolean, // Only relevant for voice
|
||||
onNegotiationNeeded: (peerIdHex: string, pc: RTCPeerConnection) => void,
|
||||
onIceCandidate: (peerIdHex: string, candidate: RTCIceCandidate) => void
|
||||
onIceCandidate: (peerIdHex: string, candidate: RTCIceCandidate) => void,
|
||||
) => {
|
||||
const [peers, setPeers] = useState<Map<string, Peer>>(new Map());
|
||||
const [peerStatuses, setPeerStatuses] = useState<Map<string, string>>(new Map());
|
||||
const [peerStats, setPeerStats] = useState<Map<string, WebRTCStats>>(new Map());
|
||||
const [peerStatuses, setPeerStatuses] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
);
|
||||
const [peerStats, setPeerStats] = useState<Map<string, WebRTCStats>>(
|
||||
new Map(),
|
||||
);
|
||||
const peersRef = useRef<Map<string, Peer>>(new Map());
|
||||
const peerStatsRef = useRef<Map<string, WebRTCStats>>(new Map());
|
||||
|
||||
const getPeer = useCallback((peerIdHex: string) => peersRef.current.get(peerIdHex), []);
|
||||
// Use refs for callbacks to avoid re-creating PC when UI state changes
|
||||
const onNegotiationNeededRef = useRef(onNegotiationNeeded);
|
||||
const onIceCandidateRef = useRef(onIceCandidate);
|
||||
const isDeafenedRef = useRef(isDeafened);
|
||||
|
||||
const createPeerConnection = useCallback((peerIdHex: string) => {
|
||||
if (peersRef.current.has(peerIdHex)) return peersRef.current.get(peerIdHex)!.pc;
|
||||
|
||||
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(() => {
|
||||
peersRef.current.forEach(peer => {
|
||||
if (peer.audio) {
|
||||
peer.audio.muted = isDeafened;
|
||||
}
|
||||
});
|
||||
onNegotiationNeededRef.current = onNegotiationNeeded;
|
||||
}, [onNegotiationNeeded]);
|
||||
useEffect(() => {
|
||||
onIceCandidateRef.current = onIceCandidate;
|
||||
}, [onIceCandidate]);
|
||||
useEffect(() => {
|
||||
isDeafenedRef.current = isDeafened;
|
||||
}, [isDeafened]);
|
||||
|
||||
const closePeer = useCallback((peerIdHex: string) => {
|
||||
|
||||
const peer = peersRef.current.get(peerIdHex);
|
||||
if (peer) {
|
||||
peer.pc.close();
|
||||
if (peer.audio) {
|
||||
peer.audio.pause();
|
||||
peer.audio.srcObject = null;
|
||||
const closePeer = useCallback(
|
||||
(peerIdHex: string) => {
|
||||
const peer = peersRef.current.get(peerIdHex);
|
||||
if (peer) {
|
||||
console.log(
|
||||
`[WebRTC][${mediaType}] Closing peer connection for ${peerIdHex}`,
|
||||
);
|
||||
peer.pc.close();
|
||||
if (peer.audio) {
|
||||
peer.audio.pause();
|
||||
peer.audio.srcObject = null;
|
||||
}
|
||||
peersRef.current.delete(peerIdHex);
|
||||
setPeers(new Map(peersRef.current));
|
||||
setPeerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(peerIdHex);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
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));
|
||||
setPeerStatuses(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(peerIdHex);
|
||||
return next;
|
||||
return pc;
|
||||
},
|
||||
[identity, mediaType, closePeer],
|
||||
);
|
||||
|
||||
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(() => {
|
||||
if (peers.size === 0) {
|
||||
if (peerStatsRef.current.size > 0) {
|
||||
@@ -185,31 +257,42 @@ export const usePeerManager = (
|
||||
const prevStats = peerStatsRef.current.get(peerIdHex);
|
||||
const currentStats: WebRTCStats = {
|
||||
audio: { bytesReceived: 0, jitter: 0, packetsLost: 0, bitrate: 0 },
|
||||
video: { bytesReceived: 0, frameWidth: 0, frameHeight: 0, framesPerSecond: 0, bitrate: 0 },
|
||||
timestamp: Date.now()
|
||||
video: {
|
||||
bytesReceived: 0,
|
||||
frameWidth: 0,
|
||||
frameHeight: 0,
|
||||
framesPerSecond: 0,
|
||||
bitrate: 0,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'inbound-rtp') {
|
||||
stats.forEach((report) => {
|
||||
if (report.type === "inbound-rtp") {
|
||||
const kind = report.kind;
|
||||
if (kind === 'audio' || kind === 'video') {
|
||||
const target = kind === 'audio' ? currentStats.audio : currentStats.video;
|
||||
if (kind === "audio" || kind === "video") {
|
||||
const target =
|
||||
kind === "audio" ? currentStats.audio : currentStats.video;
|
||||
target.bytesReceived = report.bytesReceived || 0;
|
||||
if (kind === 'audio') {
|
||||
if (kind === "audio") {
|
||||
currentStats.audio.jitter = report.jitter || 0;
|
||||
currentStats.audio.packetsLost = report.packetsLost || 0;
|
||||
} else {
|
||||
currentStats.video.frameWidth = report.frameWidth || 0;
|
||||
currentStats.video.frameHeight = report.frameHeight || 0;
|
||||
currentStats.video.framesPerSecond = report.framesPerSecond || 0;
|
||||
currentStats.video.framesPerSecond =
|
||||
report.framesPerSecond || 0;
|
||||
}
|
||||
|
||||
if (prevStats) {
|
||||
const prevTarget = kind === 'audio' ? prevStats.audio : prevStats.video;
|
||||
const deltaBytes = target.bytesReceived - prevTarget.bytesReceived;
|
||||
const deltaTime = (currentStats.timestamp - prevStats.timestamp) / 1000;
|
||||
const prevTarget =
|
||||
kind === "audio" ? prevStats.audio : prevStats.video;
|
||||
const deltaBytes =
|
||||
target.bytesReceived - prevTarget.bytesReceived;
|
||||
const deltaTime =
|
||||
(currentStats.timestamp - prevStats.timestamp) / 1000;
|
||||
if (deltaTime > 0) {
|
||||
target.bitrate = Math.max(0, deltaBytes * 8 / deltaTime);
|
||||
target.bitrate = Math.max(0, (deltaBytes * 8) / deltaTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +300,10 @@ export const usePeerManager = (
|
||||
});
|
||||
newStats.set(peerIdHex, currentStats);
|
||||
} catch (e) {
|
||||
console.warn(`[WebRTC] Failed to get stats for ${peerIdHex}`, e);
|
||||
console.warn(
|
||||
`[WebRTC][${mediaType}] Failed to get stats for ${peerIdHex}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
peerStatsRef.current = newStats;
|
||||
@@ -225,15 +311,18 @@ export const usePeerManager = (
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [peers]);
|
||||
}, [peers, mediaType]);
|
||||
|
||||
return {
|
||||
peers,
|
||||
peerStatuses,
|
||||
peerStats,
|
||||
createPeerConnection,
|
||||
closePeer,
|
||||
getPeer,
|
||||
peersRef
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
peers,
|
||||
peerStatuses,
|
||||
peerStats,
|
||||
createPeerConnection,
|
||||
closePeer,
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { useTable, useReducer } from "spacetimedb/react";
|
||||
import { tables, reducers } from "../../../module_bindings";
|
||||
@@ -6,10 +6,11 @@ import { tables, reducers } from "../../../module_bindings";
|
||||
export const useSignaling = (
|
||||
identity: Identity | null,
|
||||
connectedChannelId: bigint | undefined,
|
||||
mediaType: "voice" | "screen",
|
||||
createPeerConnection: (peerIdHex: string) => RTCPeerConnection,
|
||||
getPeer: (peerIdHex: string) => any,
|
||||
makingOfferRef: React.MutableRefObject<Map<string, boolean>>,
|
||||
ignoreOfferRef: React.MutableRefObject<Map<string, boolean>>
|
||||
ignoreOfferRef: React.MutableRefObject<Map<string, boolean>>,
|
||||
) => {
|
||||
const [offers] = useTable(tables.sdp_offer);
|
||||
const [answers] = useTable(tables.sdp_answer);
|
||||
@@ -22,45 +23,54 @@ export const useSignaling = (
|
||||
const processedCandidatesRef = useRef<Set<bigint>>(new Set());
|
||||
const candidateQueueRef = useRef<Map<string, any[]>>(new Map());
|
||||
|
||||
const drainCandidateQueue = useCallback(
|
||||
async (peerIdHex: string, pc: RTCPeerConnection) => {
|
||||
const queue = candidateQueueRef.current.get(peerIdHex) || [];
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const drainCandidateQueue = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
|
||||
const queue = candidateQueueRef.current.get(peerIdHex) || [];
|
||||
if (queue.length === 0) return;
|
||||
|
||||
// Safety: ensure we have a remote description before draining
|
||||
if (!pc.remoteDescription) {
|
||||
console.warn(`[WebRTC] Attempted to drain candidates for ${peerIdHex} but no remote description exists`);
|
||||
if (!pc.remoteDescription) {
|
||||
console.warn(
|
||||
`[WebRTC][${mediaType}] Attempted to drain candidates for ${peerIdHex} but no remote description exists`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[WebRTC] Draining ${queue.length} queued candidates for ${peerIdHex}`);
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const myOffers = offers.filter(o =>
|
||||
o.receiver.isEqual(identity) &&
|
||||
!o.sender.isEqual(identity) &&
|
||||
o.channelId === connectedChannelId
|
||||
const myOffers = offers.filter(
|
||||
(o) =>
|
||||
o.receiver.isEqual(identity) &&
|
||||
!o.sender.isEqual(identity) &&
|
||||
o.channelId === connectedChannelId &&
|
||||
o.kind === mediaType,
|
||||
);
|
||||
|
||||
const processOffers = async () => {
|
||||
for (const offerRow of myOffers) {
|
||||
if (processedOffersRef.current.has(offerRow.id)) continue;
|
||||
// Mark as processed immediately to prevent duplicate processing during async gaps
|
||||
processedOffersRef.current.add(offerRow.id);
|
||||
|
||||
const peerIdHex = offerRow.sender.toHexString();
|
||||
console.log(`[WebRTC] Received offer from ${peerIdHex}`);
|
||||
console.log(`[WebRTC][${mediaType}] Received offer from ${peerIdHex}`);
|
||||
const pc = createPeerConnection(peerIdHex);
|
||||
if (!pc) continue;
|
||||
const offer = JSON.parse(offerRow.sdp);
|
||||
@@ -68,43 +78,69 @@ export const useSignaling = (
|
||||
try {
|
||||
const isPolite = identity.toHexString() < peerIdHex;
|
||||
const makingOffer = makingOfferRef.current.get(peerIdHex) || false;
|
||||
const offerCollision = (pc.signalingState !== "stable") || makingOffer;
|
||||
const offerCollision = pc.signalingState !== "stable" || makingOffer;
|
||||
|
||||
const ignoreOffer = !isPolite && offerCollision;
|
||||
ignoreOfferRef.current.set(peerIdHex, ignoreOffer);
|
||||
if (ignoreOffer) {
|
||||
console.log(`[WebRTC] Ignoring offer collision from ${peerIdHex} (Impolite)`);
|
||||
console.log(
|
||||
`[WebRTC][${mediaType}] Ignoring offer collision from ${peerIdHex} (Impolite)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (offerCollision) {
|
||||
console.log(`[WebRTC] Handling offer collision from ${peerIdHex} (Polite), rolling back...`);
|
||||
console.log(
|
||||
`[WebRTC][${mediaType}] Handling offer collision from ${peerIdHex} (Polite), rolling back...`,
|
||||
);
|
||||
await pc.setLocalDescription({ type: "rollback" as RTCSdpType });
|
||||
}
|
||||
|
||||
console.log(`[WebRTC] Setting remote description for ${peerIdHex}`);
|
||||
console.log(
|
||||
`[WebRTC][${mediaType}] Setting remote description for ${peerIdHex}`,
|
||||
);
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
console.log(`[WebRTC] Sending answer to ${peerIdHex}`);
|
||||
sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId });
|
||||
console.log(`[WebRTC][${mediaType}] Sending answer to ${peerIdHex}`);
|
||||
sendSdpAnswer({
|
||||
receiver: offerRow.sender,
|
||||
sdp: JSON.stringify(answer),
|
||||
channelId: connectedChannelId,
|
||||
kind: mediaType,
|
||||
});
|
||||
|
||||
await drainCandidateQueue(peerIdHex, pc);
|
||||
} catch (e) {
|
||||
console.error(`[WebRTC] Error handling offer from ${peerIdHex}`, e);
|
||||
console.error(
|
||||
`[WebRTC][${mediaType}] Error handling offer from ${peerIdHex}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
processOffers();
|
||||
}, [offers, connectedChannelId, identity, createPeerConnection, sendSdpAnswer, drainCandidateQueue]);
|
||||
}, [
|
||||
offers,
|
||||
connectedChannelId,
|
||||
identity,
|
||||
createPeerConnection,
|
||||
sendSdpAnswer,
|
||||
drainCandidateQueue,
|
||||
mediaType,
|
||||
makingOfferRef,
|
||||
ignoreOfferRef,
|
||||
]);
|
||||
|
||||
// Handle Answers
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const myAnswers = answers.filter(a =>
|
||||
a.receiver.isEqual(identity) &&
|
||||
!a.sender.isEqual(identity) &&
|
||||
a.channelId === connectedChannelId
|
||||
const myAnswers = answers.filter(
|
||||
(a) =>
|
||||
a.receiver.isEqual(identity) &&
|
||||
!a.sender.isEqual(identity) &&
|
||||
a.channelId === connectedChannelId &&
|
||||
a.kind === mediaType,
|
||||
);
|
||||
|
||||
const processAnswers = async () => {
|
||||
@@ -116,26 +152,46 @@ export const useSignaling = (
|
||||
const peer = getPeer(peerIdHex);
|
||||
if (peer) {
|
||||
try {
|
||||
console.log(`[WebRTC] Received answer from ${peerIdHex}`);
|
||||
console.log(
|
||||
`[WebRTC][${mediaType}] Received answer from ${peerIdHex}`,
|
||||
);
|
||||
const answer = JSON.parse(answerRow.sdp);
|
||||
await peer.pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
await peer.pc.setRemoteDescription(
|
||||
new RTCSessionDescription(answer),
|
||||
);
|
||||
await drainCandidateQueue(peerIdHex, peer.pc);
|
||||
} catch (e) { console.error(`[WebRTC] Error handling answer from ${peerIdHex}`, e); }
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[WebRTC][${mediaType}] Error handling answer from ${peerIdHex}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[WebRTC] Received answer from ${peerIdHex} but no PeerConnection exists`);
|
||||
console.warn(
|
||||
`[WebRTC][${mediaType}] Received answer from ${peerIdHex} but no PeerConnection exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
processAnswers();
|
||||
}, [answers, connectedChannelId, identity, getPeer, drainCandidateQueue]);
|
||||
}, [
|
||||
answers,
|
||||
connectedChannelId,
|
||||
identity,
|
||||
getPeer,
|
||||
drainCandidateQueue,
|
||||
mediaType,
|
||||
]);
|
||||
|
||||
// Handle ICE Candidates
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) return;
|
||||
const myCandidates = iceCandidates.filter(c =>
|
||||
c.receiver.isEqual(identity) &&
|
||||
!c.sender.isEqual(identity) &&
|
||||
c.channelId === connectedChannelId
|
||||
const myCandidates = iceCandidates.filter(
|
||||
(c) =>
|
||||
c.receiver.isEqual(identity) &&
|
||||
!c.sender.isEqual(identity) &&
|
||||
c.channelId === connectedChannelId &&
|
||||
c.kind === mediaType,
|
||||
);
|
||||
|
||||
const processCandidates = async () => {
|
||||
@@ -144,7 +200,6 @@ export const useSignaling = (
|
||||
processedCandidatesRef.current.add(candRow.id);
|
||||
|
||||
const peerIdHex = candRow.sender.toHexString();
|
||||
// Ensure PeerConnection exists if we get a candidate
|
||||
const pc = createPeerConnection(peerIdHex);
|
||||
if (!pc) continue;
|
||||
|
||||
@@ -153,24 +208,35 @@ export const useSignaling = (
|
||||
const candidate = JSON.parse(candRow.candidate);
|
||||
|
||||
if (pc.remoteDescription) {
|
||||
console.log(`[WebRTC] Adding ICE candidate from ${peerIdHex}`);
|
||||
console.log(
|
||||
`[WebRTC][${mediaType}] Adding ICE candidate from ${peerIdHex}`,
|
||||
);
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} else if (!ignoreOffer) {
|
||||
console.log(`[WebRTC] Queueing ICE candidate from ${peerIdHex}`);
|
||||
console.log(
|
||||
`[WebRTC][${mediaType}] Queueing ICE candidate from ${peerIdHex}`,
|
||||
);
|
||||
const queue = candidateQueueRef.current.get(peerIdHex) || [];
|
||||
queue.push(candidate);
|
||||
candidateQueueRef.current.set(peerIdHex, queue);
|
||||
} else {
|
||||
console.log(`[WebRTC] Ignoring ICE candidate from ${peerIdHex} (ignoreOffer=true)`);
|
||||
}
|
||||
} catch (e) { console.error(`[WebRTC] Error handling ICE from ${peerIdHex}`, e); }
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[WebRTC][${mediaType}] Error handling ICE from ${peerIdHex}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
processCandidates();
|
||||
}, [iceCandidates, connectedChannelId, identity, createPeerConnection]);
|
||||
|
||||
|
||||
|
||||
}, [
|
||||
iceCandidates,
|
||||
connectedChannelId,
|
||||
identity,
|
||||
createPeerConnection,
|
||||
mediaType,
|
||||
ignoreOfferRef,
|
||||
]);
|
||||
|
||||
const clearSignalingState = useCallback(() => {
|
||||
processedOffersRef.current.clear();
|
||||
@@ -179,10 +245,12 @@ export const useSignaling = (
|
||||
makingOfferRef.current.clear();
|
||||
ignoreOfferRef.current.clear();
|
||||
candidateQueueRef.current.clear();
|
||||
}, []);
|
||||
}, [makingOfferRef, ignoreOfferRef]);
|
||||
|
||||
return {
|
||||
makingOfferRef,
|
||||
clearSignalingState
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
clearSignalingState,
|
||||
}),
|
||||
[clearSignalingState],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import { useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react";
|
||||
import { tables, reducers } from "../../../module_bindings";
|
||||
import { useLocalMedia } from "./useLocalMedia";
|
||||
import { usePeerManager } from "./usePeerManager";
|
||||
import { useSignaling } from "./useSignaling";
|
||||
import { useChannelAudioWebRTC } from "./useChannelAudioWebRTC";
|
||||
import { useScreenSharingWebRTC } from "./useScreenSharingWebRTC";
|
||||
|
||||
export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
const { identity } = useSpacetimeDB();
|
||||
const [voiceStates] = useTable(tables.voice_state);
|
||||
const [watching] = useTable(tables.watching);
|
||||
|
||||
const sendSdpOffer = useReducer(reducers.sendSdpOffer);
|
||||
const sendIceCandidate = useReducer(reducers.sendIceCandidate);
|
||||
const startWatchingReducer = useReducer(reducers.startWatching);
|
||||
const stopWatchingReducer = useReducer(reducers.stopWatching);
|
||||
|
||||
// Refs for signaling state to avoid circular dependencies and stale closures
|
||||
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
|
||||
const ignoreOfferRef = useRef<Map<string, boolean>>(new Map());
|
||||
const connectedChannelIdRef = useRef<bigint | undefined>(connectedChannelId);
|
||||
|
||||
useEffect(() => {
|
||||
connectedChannelIdRef.current = connectedChannelId;
|
||||
}, [connectedChannelId]);
|
||||
|
||||
const {
|
||||
localStream,
|
||||
localScreenStream,
|
||||
@@ -38,179 +26,29 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
stopScreenShare: stopLocalScreenShare,
|
||||
requestMic,
|
||||
releaseMic,
|
||||
localStreamRef,
|
||||
localScreenStreamRef
|
||||
} = useLocalMedia();
|
||||
|
||||
const onNegotiationNeeded = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
|
||||
// Always check the LATEST channel ID from ref
|
||||
const channelId = connectedChannelIdRef.current;
|
||||
const isMakingOffer = makingOfferRef.current.get(peerIdHex);
|
||||
|
||||
if (!channelId || pc.signalingState !== 'stable' || isMakingOffer) {
|
||||
console.log(`[WebRTC] Skipping negotiation for ${peerIdHex}: channel=${!!channelId}, state=${pc.signalingState}, makingOffer=${isMakingOffer}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
makingOfferRef.current.set(peerIdHex, true);
|
||||
console.log(`[WebRTC] Negotiation needed for ${peerIdHex}, creating offer...`);
|
||||
await pc.setLocalDescription();
|
||||
console.log(`[WebRTC] Sending offer to ${peerIdHex}`);
|
||||
sendSdpOffer({
|
||||
receiver: Identity.fromString(peerIdHex),
|
||||
sdp: JSON.stringify(pc.localDescription),
|
||||
channelId
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[WebRTC] Error during negotiation for ${peerIdHex}`, e);
|
||||
} finally {
|
||||
makingOfferRef.current.set(peerIdHex, false);
|
||||
}
|
||||
}, [sendSdpOffer]);
|
||||
|
||||
|
||||
const onIceCandidate = useCallback((peerIdHex: string, candidate: RTCIceCandidate) => {
|
||||
const channelId = connectedChannelIdRef.current;
|
||||
if (channelId) {
|
||||
sendIceCandidate({
|
||||
receiver: Identity.fromString(peerIdHex),
|
||||
candidate: JSON.stringify(candidate),
|
||||
channelId
|
||||
});
|
||||
}
|
||||
}, [sendIceCandidate]);
|
||||
|
||||
const {
|
||||
peers,
|
||||
peerStatuses,
|
||||
peerStats,
|
||||
createPeerConnection,
|
||||
closePeer,
|
||||
getPeer,
|
||||
peersRef
|
||||
} = usePeerManager(
|
||||
identity,
|
||||
isDeafened,
|
||||
localStreamRef,
|
||||
localScreenStreamRef,
|
||||
onNegotiationNeeded,
|
||||
onIceCandidate
|
||||
);
|
||||
|
||||
const {
|
||||
clearSignalingState
|
||||
} = useSignaling(
|
||||
identity,
|
||||
// --- Specialized Hooks ---
|
||||
const voice = useChannelAudioWebRTC(
|
||||
connectedChannelId,
|
||||
createPeerConnection,
|
||||
getPeer,
|
||||
makingOfferRef,
|
||||
ignoreOfferRef
|
||||
identity,
|
||||
localStream,
|
||||
isDeafened
|
||||
);
|
||||
|
||||
// Sync local media to existing peers
|
||||
useEffect(() => {
|
||||
const audioTrack = localStream?.getAudioTracks()[0] || null;
|
||||
peersRef.current.forEach(async (peer, peerIdHex) => {
|
||||
const transceivers = peer.pc.getTransceivers();
|
||||
if (transceivers[0] && transceivers[0].sender.track !== audioTrack) {
|
||||
console.log(`[WebRTC] Syncing audio track to peer ${peerIdHex}`);
|
||||
try {
|
||||
await transceivers[0].sender.replaceTrack(audioTrack);
|
||||
if (peer.pc.signalingState === 'stable') {
|
||||
onNegotiationNeeded(peerIdHex, peer.pc);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[WebRTC] Error replacing audio track for ${peerIdHex}`, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [localStream, peers, onNegotiationNeeded]);
|
||||
const screen = useScreenSharingWebRTC(
|
||||
connectedChannelId,
|
||||
identity,
|
||||
localScreenStream
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const videoTrack = localScreenStream?.getVideoTracks()[0] || null;
|
||||
peersRef.current.forEach(async (peer, peerIdHex) => {
|
||||
const transceivers = peer.pc.getTransceivers();
|
||||
if (transceivers[1] && transceivers[1].sender.track !== videoTrack) {
|
||||
console.log(`[WebRTC] Syncing video track to peer ${peerIdHex}`);
|
||||
try {
|
||||
await transceivers[1].sender.replaceTrack(videoTrack);
|
||||
if (peer.pc.signalingState === 'stable') {
|
||||
onNegotiationNeeded(peerIdHex, peer.pc);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[WebRTC] Error replacing video track for ${peerIdHex}`, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [localScreenStream, peers, onNegotiationNeeded]);
|
||||
|
||||
|
||||
|
||||
// Determine who to connect to
|
||||
const peersToConnect = useMemo(() => {
|
||||
if (!identity || !connectedChannelId) return new Set<string>();
|
||||
const peerIds = new Set<string>();
|
||||
voiceStates.forEach(vs => {
|
||||
if (vs.channelId === connectedChannelId && !vs.identity.isEqual(identity)) {
|
||||
peerIds.add(vs.identity.toHexString());
|
||||
}
|
||||
});
|
||||
watching.forEach(w => {
|
||||
if (w.watcher.isEqual(identity)) {
|
||||
peerIds.add(w.watchee.toHexString());
|
||||
} else if (w.watchee.isEqual(identity)) {
|
||||
peerIds.add(w.watcher.toHexString());
|
||||
}
|
||||
});
|
||||
return peerIds;
|
||||
}, [voiceStates, watching, identity, connectedChannelId]);
|
||||
|
||||
// Peer Lifecycle Orchestration
|
||||
useEffect(() => {
|
||||
if (!connectedChannelId || !identity) {
|
||||
// Cleanup all
|
||||
if (peersRef.current.size > 0) {
|
||||
console.log("[WebRTC] Cleaning up all peer connections");
|
||||
peersRef.current.forEach((_, peerIdHex) => closePeer(peerIdHex));
|
||||
}
|
||||
releaseMic();
|
||||
clearSignalingState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Always clear signaling state when connectedChannelId changes to avoid stale row processing
|
||||
clearSignalingState();
|
||||
|
||||
// Connect to new peers
|
||||
peersToConnect.forEach(peerIdHex => {
|
||||
if (!peersRef.current.has(peerIdHex)) {
|
||||
createPeerConnection(peerIdHex);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup disconnected peers
|
||||
peersRef.current.forEach((_, peerIdHex) => {
|
||||
if (!peersToConnect.has(peerIdHex)) {
|
||||
closePeer(peerIdHex);
|
||||
}
|
||||
});
|
||||
|
||||
requestMic();
|
||||
}, [peersToConnect, connectedChannelId, identity, createPeerConnection, closePeer, requestMic, releaseMic, clearSignalingState]);
|
||||
|
||||
// Screen Share Actions
|
||||
// --- Actions ---
|
||||
const startScreenShare = useCallback(() => {
|
||||
startLocalScreenShare((track) => {
|
||||
// Handled by localScreenStream effect
|
||||
});
|
||||
startLocalScreenShare(() => {});
|
||||
}, [startLocalScreenShare]);
|
||||
|
||||
const stopScreenShare = useCallback(() => {
|
||||
stopLocalScreenShare((track) => {
|
||||
// Handled by localScreenStream effect
|
||||
});
|
||||
stopLocalScreenShare(() => {});
|
||||
}, [stopLocalScreenShare]);
|
||||
|
||||
const startWatching = useCallback((peerIdentity: Identity) => {
|
||||
@@ -226,8 +64,8 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
return {
|
||||
localStream,
|
||||
localScreenStream,
|
||||
peerStatuses,
|
||||
peers,
|
||||
peerStatuses: voice.peerStatuses,
|
||||
peers: screen.peers, // For VideoGrid to show streams
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
isSharingScreen,
|
||||
@@ -238,9 +76,8 @@ export const useWebRTC = (connectedChannelId: bigint | undefined) => {
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
peerStats
|
||||
peerStats: voice.peerStats
|
||||
};
|
||||
};
|
||||
|
||||
export default useWebRTC;
|
||||
|
||||
|
||||
+3
-3
@@ -33,15 +33,15 @@ body,
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* ----- Buttons ----- */
|
||||
|
||||
+31
-23
@@ -1,10 +1,10 @@
|
||||
import { StrictMode, useMemo } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import { Identity } from 'spacetimedb';
|
||||
import { SpacetimeDBProvider } from 'spacetimedb/react';
|
||||
import { DbConnection, ErrorContext } from './module_bindings/index.ts';
|
||||
import { StrictMode, useMemo } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { Identity } from "spacetimedb";
|
||||
import { SpacetimeDBProvider } from "spacetimedb/react";
|
||||
import { DbConnection, ErrorContext } from "./module_bindings/index.ts";
|
||||
import { OidcProvider } from "./auth"; // Import from index.ts
|
||||
import { AuthGate } from "./auth"; // Import from index.ts
|
||||
|
||||
@@ -12,24 +12,26 @@ import { AuthGate } from "./auth"; // Import from index.ts
|
||||
// which it does. SpacetimeDBWrapper relies on useAuth to get the OIDC token.
|
||||
import { useAuth } from "react-oidc-context";
|
||||
|
||||
const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'wss://maincloud.spacetimedb.com';
|
||||
const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'my-spacetime-app-jdhdg';
|
||||
const HOST =
|
||||
import.meta.env.VITE_SPACETIMEDB_HOST ?? "wss://maincloud.spacetimedb.com";
|
||||
const DB_NAME =
|
||||
import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? "my-spacetime-app-jdhdg";
|
||||
export const TOKEN_KEY = `${HOST}/${DB_NAME}/auth_token`;
|
||||
|
||||
const onConnect = (conn: DbConnection, identity: Identity, token: string) => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
console.log(
|
||||
'Connected to SpacetimeDB with identity:',
|
||||
identity.toHexString()
|
||||
"Connected to SpacetimeDB with identity:",
|
||||
identity.toHexString(),
|
||||
);
|
||||
};
|
||||
|
||||
const onDisconnect = () => {
|
||||
console.log('Disconnected from SpacetimeDB');
|
||||
console.log("Disconnected from SpacetimeDB");
|
||||
};
|
||||
|
||||
const onConnectError = (_ctx: ErrorContext, err: Error) => {
|
||||
console.log('Error connecting to SpacetimeDB:', err);
|
||||
console.log("Error connecting to SpacetimeDB:", err);
|
||||
};
|
||||
|
||||
// This component remains responsible for the SpacetimeDB connection logic.
|
||||
@@ -38,11 +40,14 @@ function SpacetimeDBWrapper({ children }: { children: React.ReactNode }) {
|
||||
const auth = useAuth();
|
||||
|
||||
// Logging authentication and token status
|
||||
console.log('SpacetimeDBWrapper: auth.isLoading:', auth.isLoading);
|
||||
console.log('SpacetimeDBWrapper: auth.isAuthenticated:', auth.isAuthenticated);
|
||||
console.log('SpacetimeDBWrapper: auth.user?.id_token:', auth.user?.id_token);
|
||||
console.log("SpacetimeDBWrapper: auth.isLoading:", auth.isLoading);
|
||||
console.log(
|
||||
"SpacetimeDBWrapper: auth.isAuthenticated:",
|
||||
auth.isAuthenticated,
|
||||
);
|
||||
console.log("SpacetimeDBWrapper: auth.user?.id_token:", auth.user?.id_token);
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||
console.log('SpacetimeDBWrapper: localStorage TOKEN_KEY:', storedToken);
|
||||
console.log("SpacetimeDBWrapper: localStorage TOKEN_KEY:", storedToken);
|
||||
|
||||
const connectionBuilder = useMemo(() => {
|
||||
const builder = DbConnection.builder()
|
||||
@@ -58,16 +63,19 @@ function SpacetimeDBWrapper({ children }: { children: React.ReactNode }) {
|
||||
console.log("SpacetimeDBWrapper: Connecting with OIDC token");
|
||||
return builder.withToken(auth.user.id_token);
|
||||
} else if (storedToken) {
|
||||
console.log("SpacetimeDBWrapper: Connecting with stored SpacetimeDB token");
|
||||
console.log(
|
||||
"SpacetimeDBWrapper: Connecting with stored SpacetimeDB token",
|
||||
);
|
||||
return builder.withToken(storedToken);
|
||||
} else {
|
||||
console.log("SpacetimeDBWrapper: No token available, proceeding without.");
|
||||
console.log(
|
||||
"SpacetimeDBWrapper: No token available, proceeding without.",
|
||||
);
|
||||
return builder; // Proceed without a token if none is available
|
||||
}
|
||||
|
||||
}, [auth.isAuthenticated, auth.user?.id_token, storedToken]); // Include storedToken in dependencies
|
||||
|
||||
console.log('SpacetimeDBWrapper: connectionBuilder created.');
|
||||
console.log("SpacetimeDBWrapper: connectionBuilder created.");
|
||||
|
||||
return (
|
||||
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
|
||||
@@ -76,7 +84,7 @@ function SpacetimeDBWrapper({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<OidcProvider>
|
||||
<SpacetimeDBWrapper>
|
||||
@@ -86,5 +94,5 @@ createRoot(document.getElementById('root')!).render(
|
||||
</AuthGate>
|
||||
</SpacetimeDBWrapper>
|
||||
</OidcProvider>
|
||||
</StrictMode>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
Generated
+1
-4
@@ -9,10 +9,7 @@ import {
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
ChannelKind,
|
||||
} from "./types";
|
||||
|
||||
import { ChannelKind } from "./types";
|
||||
|
||||
export default __t.row({
|
||||
id: __t.u64().primaryKey(),
|
||||
|
||||
Generated
+445
-188
@@ -43,10 +43,13 @@ import LeaveServerReducer from "./leave_server_reducer";
|
||||
import LeaveVoiceReducer from "./leave_voice_reducer";
|
||||
import LoginReducer from "./login_reducer";
|
||||
import RegisterReducer from "./register_reducer";
|
||||
import SendIceCandidateReducer from "./send_ice_candidate_reducer";
|
||||
import SendMessageReducer from "./send_message_reducer";
|
||||
import SendSdpAnswerReducer from "./send_sdp_answer_reducer";
|
||||
import SendSdpOfferReducer from "./send_sdp_offer_reducer";
|
||||
import SendScreenIceCandidateReducer from "./send_screen_ice_candidate_reducer";
|
||||
import SendScreenSdpAnswerReducer from "./send_screen_sdp_answer_reducer";
|
||||
import SendScreenSdpOfferReducer from "./send_screen_sdp_offer_reducer";
|
||||
import SendVoiceIceCandidateReducer from "./send_voice_ice_candidate_reducer";
|
||||
import SendVoiceSdpAnswerReducer from "./send_voice_sdp_answer_reducer";
|
||||
import SendVoiceSdpOfferReducer from "./send_voice_sdp_offer_reducer";
|
||||
import SetNameReducer from "./set_name_reducer";
|
||||
import SetSharingScreenReducer from "./set_sharing_screen_reducer";
|
||||
import SetTalkingReducer from "./set_talking_reducer";
|
||||
@@ -57,14 +60,17 @@ import StopWatchingReducer from "./stop_watching_reducer";
|
||||
|
||||
// Import all table schema definitions
|
||||
import ChannelRow from "./channel_table";
|
||||
import IceCandidateRow from "./ice_candidate_table";
|
||||
import MessageRow from "./message_table";
|
||||
import SdpAnswerRow from "./sdp_answer_table";
|
||||
import SdpOfferRow from "./sdp_offer_table";
|
||||
import ScreenIceCandidateRow from "./screen_ice_candidate_table";
|
||||
import ScreenSdpAnswerRow from "./screen_sdp_answer_table";
|
||||
import ScreenSdpOfferRow from "./screen_sdp_offer_table";
|
||||
import ServerRow from "./server_table";
|
||||
import ServerMemberRow from "./server_member_table";
|
||||
import ThreadRow from "./thread_table";
|
||||
import UserRow from "./user_table";
|
||||
import VoiceIceCandidateRow from "./voice_ice_candidate_table";
|
||||
import VoiceSdpAnswerRow from "./voice_sdp_answer_table";
|
||||
import VoiceSdpOfferRow from "./voice_sdp_offer_table";
|
||||
import VoiceStateRow from "./voice_state_table";
|
||||
import WatchingRow from "./watching_table";
|
||||
|
||||
@@ -72,176 +78,413 @@ import WatchingRow from "./watching_table";
|
||||
|
||||
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
|
||||
const tablesSchema = __schema({
|
||||
channel: __table({
|
||||
name: 'channel',
|
||||
indexes: [
|
||||
{ accessor: 'id', name: 'channel_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'id',
|
||||
] },
|
||||
{ accessor: 'by_server_id', name: 'channel_server_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'serverId',
|
||||
] },
|
||||
],
|
||||
constraints: [
|
||||
{ name: 'channel_id_key', constraint: 'unique', columns: ['id'] },
|
||||
],
|
||||
}, ChannelRow),
|
||||
ice_candidate: __table({
|
||||
name: 'ice_candidate',
|
||||
indexes: [
|
||||
{ accessor: 'id', name: 'ice_candidate_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'id',
|
||||
] },
|
||||
{ accessor: 'by_receiver', name: 'ice_candidate_receiver_idx_btree', algorithm: 'btree', columns: [
|
||||
'receiver',
|
||||
] },
|
||||
{ accessor: 'by_sender', name: 'ice_candidate_sender_idx_btree', algorithm: 'btree', columns: [
|
||||
'sender',
|
||||
] },
|
||||
],
|
||||
constraints: [
|
||||
{ name: 'ice_candidate_id_key', constraint: 'unique', columns: ['id'] },
|
||||
],
|
||||
}, IceCandidateRow),
|
||||
message: __table({
|
||||
name: 'message',
|
||||
indexes: [
|
||||
{ accessor: 'by_channel_id', name: 'message_channel_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'channelId',
|
||||
] },
|
||||
{ accessor: 'id', name: 'message_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'id',
|
||||
] },
|
||||
{ accessor: 'by_thread_id', name: 'message_thread_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'threadId',
|
||||
] },
|
||||
],
|
||||
constraints: [
|
||||
{ name: 'message_id_key', constraint: 'unique', columns: ['id'] },
|
||||
],
|
||||
}, MessageRow),
|
||||
sdp_answer: __table({
|
||||
name: 'sdp_answer',
|
||||
indexes: [
|
||||
{ accessor: 'id', name: 'sdp_answer_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'id',
|
||||
] },
|
||||
{ accessor: 'by_receiver', name: 'sdp_answer_receiver_idx_btree', algorithm: 'btree', columns: [
|
||||
'receiver',
|
||||
] },
|
||||
{ accessor: 'by_sender', name: 'sdp_answer_sender_idx_btree', algorithm: 'btree', columns: [
|
||||
'sender',
|
||||
] },
|
||||
],
|
||||
constraints: [
|
||||
{ name: 'sdp_answer_id_key', constraint: 'unique', columns: ['id'] },
|
||||
],
|
||||
}, SdpAnswerRow),
|
||||
sdp_offer: __table({
|
||||
name: 'sdp_offer',
|
||||
indexes: [
|
||||
{ accessor: 'id', name: 'sdp_offer_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'id',
|
||||
] },
|
||||
{ accessor: 'by_receiver', name: 'sdp_offer_receiver_idx_btree', algorithm: 'btree', columns: [
|
||||
'receiver',
|
||||
] },
|
||||
{ accessor: 'by_sender', name: 'sdp_offer_sender_idx_btree', algorithm: 'btree', columns: [
|
||||
'sender',
|
||||
] },
|
||||
],
|
||||
constraints: [
|
||||
{ name: 'sdp_offer_id_key', constraint: 'unique', columns: ['id'] },
|
||||
],
|
||||
}, SdpOfferRow),
|
||||
server: __table({
|
||||
name: 'server',
|
||||
indexes: [
|
||||
{ accessor: 'id', name: 'server_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'id',
|
||||
] },
|
||||
],
|
||||
constraints: [
|
||||
{ name: 'server_id_key', constraint: 'unique', columns: ['id'] },
|
||||
],
|
||||
}, ServerRow),
|
||||
server_member: __table({
|
||||
name: 'server_member',
|
||||
indexes: [
|
||||
{ accessor: 'id', name: 'server_member_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'id',
|
||||
] },
|
||||
{ accessor: 'by_identity', name: 'server_member_identity_idx_btree', algorithm: 'btree', columns: [
|
||||
'identity',
|
||||
] },
|
||||
{ accessor: 'by_server_id', name: 'server_member_server_id_idx_btree', algorithm: 'btree', columns: [
|
||||
'serverId',
|
||||
] },
|
||||
],
|
||||
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_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),
|
||||
channel: __table(
|
||||
{
|
||||
name: "channel",
|
||||
indexes: [
|
||||
{
|
||||
accessor: "id",
|
||||
name: "channel_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["id"],
|
||||
},
|
||||
{
|
||||
accessor: "by_server_id",
|
||||
name: "channel_server_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["serverId"],
|
||||
},
|
||||
],
|
||||
constraints: [
|
||||
{ name: "channel_id_key", constraint: "unique", columns: ["id"] },
|
||||
],
|
||||
},
|
||||
ChannelRow,
|
||||
),
|
||||
message: __table(
|
||||
{
|
||||
name: "message",
|
||||
indexes: [
|
||||
{
|
||||
accessor: "by_channel_id",
|
||||
name: "message_channel_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["channelId"],
|
||||
},
|
||||
{
|
||||
accessor: "id",
|
||||
name: "message_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["id"],
|
||||
},
|
||||
{
|
||||
accessor: "by_thread_id",
|
||||
name: "message_thread_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["threadId"],
|
||||
},
|
||||
],
|
||||
constraints: [
|
||||
{ name: "message_id_key", constraint: "unique", columns: ["id"] },
|
||||
],
|
||||
},
|
||||
MessageRow,
|
||||
),
|
||||
screen_ice_candidate: __table(
|
||||
{
|
||||
name: "screen_ice_candidate",
|
||||
indexes: [
|
||||
{
|
||||
accessor: "id",
|
||||
name: "screen_ice_candidate_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["id"],
|
||||
},
|
||||
{
|
||||
accessor: "by_receiver",
|
||||
name: "screen_ice_candidate_receiver_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["receiver"],
|
||||
},
|
||||
{
|
||||
accessor: "by_sender",
|
||||
name: "screen_ice_candidate_sender_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["sender"],
|
||||
},
|
||||
],
|
||||
constraints: [
|
||||
{
|
||||
name: "screen_ice_candidate_id_key",
|
||||
constraint: "unique",
|
||||
columns: ["id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
ScreenIceCandidateRow,
|
||||
),
|
||||
screen_sdp_answer: __table(
|
||||
{
|
||||
name: "screen_sdp_answer",
|
||||
indexes: [
|
||||
{
|
||||
accessor: "id",
|
||||
name: "screen_sdp_answer_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["id"],
|
||||
},
|
||||
{
|
||||
accessor: "by_receiver",
|
||||
name: "screen_sdp_answer_receiver_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["receiver"],
|
||||
},
|
||||
{
|
||||
accessor: "by_sender",
|
||||
name: "screen_sdp_answer_sender_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["sender"],
|
||||
},
|
||||
],
|
||||
constraints: [
|
||||
{
|
||||
name: "screen_sdp_answer_id_key",
|
||||
constraint: "unique",
|
||||
columns: ["id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
ScreenSdpAnswerRow,
|
||||
),
|
||||
screen_sdp_offer: __table(
|
||||
{
|
||||
name: "screen_sdp_offer",
|
||||
indexes: [
|
||||
{
|
||||
accessor: "id",
|
||||
name: "screen_sdp_offer_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["id"],
|
||||
},
|
||||
{
|
||||
accessor: "by_receiver",
|
||||
name: "screen_sdp_offer_receiver_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["receiver"],
|
||||
},
|
||||
{
|
||||
accessor: "by_sender",
|
||||
name: "screen_sdp_offer_sender_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["sender"],
|
||||
},
|
||||
],
|
||||
constraints: [
|
||||
{
|
||||
name: "screen_sdp_offer_id_key",
|
||||
constraint: "unique",
|
||||
columns: ["id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
ScreenSdpOfferRow,
|
||||
),
|
||||
server: __table(
|
||||
{
|
||||
name: "server",
|
||||
indexes: [
|
||||
{
|
||||
accessor: "id",
|
||||
name: "server_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["id"],
|
||||
},
|
||||
],
|
||||
constraints: [
|
||||
{ name: "server_id_key", constraint: "unique", columns: ["id"] },
|
||||
],
|
||||
},
|
||||
ServerRow,
|
||||
),
|
||||
server_member: __table(
|
||||
{
|
||||
name: "server_member",
|
||||
indexes: [
|
||||
{
|
||||
accessor: "id",
|
||||
name: "server_member_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["id"],
|
||||
},
|
||||
{
|
||||
accessor: "by_identity",
|
||||
name: "server_member_identity_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["identity"],
|
||||
},
|
||||
{
|
||||
accessor: "by_server_id",
|
||||
name: "server_member_server_id_idx_btree",
|
||||
algorithm: "btree",
|
||||
columns: ["serverId"],
|
||||
},
|
||||
],
|
||||
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. */
|
||||
@@ -255,10 +498,13 @@ const reducersSchema = __reducers(
|
||||
__reducerSchema("leave_voice", LeaveVoiceReducer),
|
||||
__reducerSchema("login", LoginReducer),
|
||||
__reducerSchema("register", RegisterReducer),
|
||||
__reducerSchema("send_ice_candidate", SendIceCandidateReducer),
|
||||
__reducerSchema("send_message", SendMessageReducer),
|
||||
__reducerSchema("send_sdp_answer", SendSdpAnswerReducer),
|
||||
__reducerSchema("send_sdp_offer", SendSdpOfferReducer),
|
||||
__reducerSchema("send_screen_ice_candidate", SendScreenIceCandidateReducer),
|
||||
__reducerSchema("send_screen_sdp_answer", SendScreenSdpAnswerReducer),
|
||||
__reducerSchema("send_screen_sdp_offer", SendScreenSdpOfferReducer),
|
||||
__reducerSchema("send_voice_ice_candidate", SendVoiceIceCandidateReducer),
|
||||
__reducerSchema("send_voice_sdp_answer", SendVoiceSdpAnswerReducer),
|
||||
__reducerSchema("send_voice_sdp_offer", SendVoiceSdpOfferReducer),
|
||||
__reducerSchema("set_name", SetNameReducer),
|
||||
__reducerSchema("set_sharing_screen", SetSharingScreenReducer),
|
||||
__reducerSchema("set_talking", SetTalkingReducer),
|
||||
@@ -267,8 +513,7 @@ const reducersSchema = __reducers(
|
||||
);
|
||||
|
||||
/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */
|
||||
const proceduresSchema = __procedures(
|
||||
);
|
||||
const proceduresSchema = __procedures();
|
||||
|
||||
/** The remote SpacetimeDB module schema, both runtime and type information. */
|
||||
const REMOTE_MODULE = {
|
||||
@@ -285,24 +530,33 @@ const REMOTE_MODULE = {
|
||||
>;
|
||||
|
||||
/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */
|
||||
export const tables: __QueryBuilder<typeof tablesSchema.schemaType> = __makeQueryBuilder(tablesSchema.schemaType);
|
||||
export const tables: __QueryBuilder<typeof tablesSchema.schemaType> =
|
||||
__makeQueryBuilder(tablesSchema.schemaType);
|
||||
|
||||
/** The reducers available in this remote SpacetimeDB module. */
|
||||
export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);
|
||||
export const reducers = __convertToAccessorMap(
|
||||
reducersSchema.reducersType.reducers,
|
||||
);
|
||||
|
||||
/** The context type returned in callbacks for all possible events. */
|
||||
export type EventContext = __EventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for reducer events. */
|
||||
export type ReducerEventContext = __ReducerEventContextInterface<typeof REMOTE_MODULE>;
|
||||
export type ReducerEventContext = __ReducerEventContextInterface<
|
||||
typeof REMOTE_MODULE
|
||||
>;
|
||||
/** The context type returned in callbacks for subscription events. */
|
||||
export type SubscriptionEventContext = __SubscriptionEventContextInterface<typeof REMOTE_MODULE>;
|
||||
export type SubscriptionEventContext = __SubscriptionEventContextInterface<
|
||||
typeof REMOTE_MODULE
|
||||
>;
|
||||
/** The context type returned in callbacks for error events. */
|
||||
export type ErrorContext = __ErrorContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */
|
||||
export type SubscriptionHandle = __SubscriptionHandleImpl<typeof REMOTE_MODULE>;
|
||||
|
||||
/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */
|
||||
export class SubscriptionBuilder extends __SubscriptionBuilderImpl<typeof REMOTE_MODULE> {}
|
||||
export class SubscriptionBuilder extends __SubscriptionBuilderImpl<
|
||||
typeof REMOTE_MODULE
|
||||
> {}
|
||||
|
||||
/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */
|
||||
export class DbConnectionBuilder extends __DbConnectionBuilder<DbConnection> {}
|
||||
@@ -311,7 +565,11 @@ export class DbConnectionBuilder extends __DbConnectionBuilder<DbConnection> {}
|
||||
export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
|
||||
/** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */
|
||||
static builder = (): DbConnectionBuilder => {
|
||||
return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig<typeof REMOTE_MODULE>) => new DbConnection(config));
|
||||
return new DbConnectionBuilder(
|
||||
REMOTE_MODULE,
|
||||
(config: __DbConnectionConfig<typeof REMOTE_MODULE>) =>
|
||||
new DbConnection(config),
|
||||
);
|
||||
};
|
||||
|
||||
/** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */
|
||||
@@ -319,4 +577,3 @@ export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
|
||||
return new SubscriptionBuilder(this);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Generated
@@ -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(),
|
||||
};
|
||||
@@ -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
@@ -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(),
|
||||
};
|
||||
Generated
+41
-15
@@ -27,15 +27,6 @@ export const ChannelKind = __t.enum("ChannelKind", {
|
||||
});
|
||||
export type ChannelKind = __Infer<typeof ChannelKind>;
|
||||
|
||||
export const IceCandidate = __t.object("IceCandidate", {
|
||||
id: __t.u64(),
|
||||
sender: __t.identity(),
|
||||
receiver: __t.identity(),
|
||||
candidate: __t.string(),
|
||||
channelId: __t.u64(),
|
||||
});
|
||||
export type IceCandidate = __Infer<typeof IceCandidate>;
|
||||
|
||||
export const Message = __t.object("Message", {
|
||||
id: __t.u64(),
|
||||
sender: __t.identity(),
|
||||
@@ -46,23 +37,32 @@ export const Message = __t.object("Message", {
|
||||
});
|
||||
export type Message = __Infer<typeof Message>;
|
||||
|
||||
export const SdpAnswer = __t.object("SdpAnswer", {
|
||||
export const ScreenIceCandidate = __t.object("ScreenIceCandidate", {
|
||||
id: __t.u64(),
|
||||
sender: __t.identity(),
|
||||
receiver: __t.identity(),
|
||||
sdp: __t.string(),
|
||||
candidate: __t.string(),
|
||||
channelId: __t.u64(),
|
||||
});
|
||||
export type SdpAnswer = __Infer<typeof SdpAnswer>;
|
||||
export type ScreenIceCandidate = __Infer<typeof ScreenIceCandidate>;
|
||||
|
||||
export const SdpOffer = __t.object("SdpOffer", {
|
||||
export const ScreenSdpAnswer = __t.object("ScreenSdpAnswer", {
|
||||
id: __t.u64(),
|
||||
sender: __t.identity(),
|
||||
receiver: __t.identity(),
|
||||
sdp: __t.string(),
|
||||
channelId: __t.u64(),
|
||||
});
|
||||
export type SdpOffer = __Infer<typeof SdpOffer>;
|
||||
export type ScreenSdpAnswer = __Infer<typeof ScreenSdpAnswer>;
|
||||
|
||||
export const ScreenSdpOffer = __t.object("ScreenSdpOffer", {
|
||||
id: __t.u64(),
|
||||
sender: __t.identity(),
|
||||
receiver: __t.identity(),
|
||||
sdp: __t.string(),
|
||||
channelId: __t.u64(),
|
||||
});
|
||||
export type ScreenSdpOffer = __Infer<typeof ScreenSdpOffer>;
|
||||
|
||||
export const Server = __t.object("Server", {
|
||||
id: __t.u64(),
|
||||
@@ -98,6 +98,33 @@ export const User = __t.object("User", {
|
||||
});
|
||||
export type User = __Infer<typeof User>;
|
||||
|
||||
export const VoiceIceCandidate = __t.object("VoiceIceCandidate", {
|
||||
id: __t.u64(),
|
||||
sender: __t.identity(),
|
||||
receiver: __t.identity(),
|
||||
candidate: __t.string(),
|
||||
channelId: __t.u64(),
|
||||
});
|
||||
export type VoiceIceCandidate = __Infer<typeof VoiceIceCandidate>;
|
||||
|
||||
export const VoiceSdpAnswer = __t.object("VoiceSdpAnswer", {
|
||||
id: __t.u64(),
|
||||
sender: __t.identity(),
|
||||
receiver: __t.identity(),
|
||||
sdp: __t.string(),
|
||||
channelId: __t.u64(),
|
||||
});
|
||||
export type VoiceSdpAnswer = __Infer<typeof VoiceSdpAnswer>;
|
||||
|
||||
export const VoiceSdpOffer = __t.object("VoiceSdpOffer", {
|
||||
id: __t.u64(),
|
||||
sender: __t.identity(),
|
||||
receiver: __t.identity(),
|
||||
sdp: __t.string(),
|
||||
channelId: __t.u64(),
|
||||
});
|
||||
export type VoiceSdpOffer = __Infer<typeof VoiceSdpOffer>;
|
||||
|
||||
export const VoiceState = __t.object("VoiceState", {
|
||||
identity: __t.identity(),
|
||||
channelId: __t.u64(),
|
||||
@@ -112,4 +139,3 @@ export const Watching = __t.object("Watching", {
|
||||
channelId: __t.u64(),
|
||||
});
|
||||
export type Watching = __Infer<typeof Watching>;
|
||||
|
||||
|
||||
Generated
-2
@@ -6,5 +6,3 @@
|
||||
import { type Infer as __Infer } from "spacetimedb";
|
||||
|
||||
// Import all procedure arg schemas
|
||||
|
||||
|
||||
|
||||
Generated
+22
-7
@@ -15,10 +15,13 @@ import LeaveServerReducer from "../leave_server_reducer";
|
||||
import LeaveVoiceReducer from "../leave_voice_reducer";
|
||||
import LoginReducer from "../login_reducer";
|
||||
import RegisterReducer from "../register_reducer";
|
||||
import SendIceCandidateReducer from "../send_ice_candidate_reducer";
|
||||
import SendMessageReducer from "../send_message_reducer";
|
||||
import SendSdpAnswerReducer from "../send_sdp_answer_reducer";
|
||||
import SendSdpOfferReducer from "../send_sdp_offer_reducer";
|
||||
import SendScreenIceCandidateReducer from "../send_screen_ice_candidate_reducer";
|
||||
import SendScreenSdpAnswerReducer from "../send_screen_sdp_answer_reducer";
|
||||
import SendScreenSdpOfferReducer from "../send_screen_sdp_offer_reducer";
|
||||
import SendVoiceIceCandidateReducer from "../send_voice_ice_candidate_reducer";
|
||||
import SendVoiceSdpAnswerReducer from "../send_voice_sdp_answer_reducer";
|
||||
import SendVoiceSdpOfferReducer from "../send_voice_sdp_offer_reducer";
|
||||
import SetNameReducer from "../set_name_reducer";
|
||||
import SetSharingScreenReducer from "../set_sharing_screen_reducer";
|
||||
import SetTalkingReducer from "../set_talking_reducer";
|
||||
@@ -34,13 +37,25 @@ export type LeaveServerParams = __Infer<typeof LeaveServerReducer>;
|
||||
export type LeaveVoiceParams = __Infer<typeof LeaveVoiceReducer>;
|
||||
export type LoginParams = __Infer<typeof LoginReducer>;
|
||||
export type RegisterParams = __Infer<typeof RegisterReducer>;
|
||||
export type SendIceCandidateParams = __Infer<typeof SendIceCandidateReducer>;
|
||||
export type SendMessageParams = __Infer<typeof SendMessageReducer>;
|
||||
export type SendSdpAnswerParams = __Infer<typeof SendSdpAnswerReducer>;
|
||||
export type SendSdpOfferParams = __Infer<typeof SendSdpOfferReducer>;
|
||||
export type SendScreenIceCandidateParams = __Infer<
|
||||
typeof SendScreenIceCandidateReducer
|
||||
>;
|
||||
export type SendScreenSdpAnswerParams = __Infer<
|
||||
typeof SendScreenSdpAnswerReducer
|
||||
>;
|
||||
export type SendScreenSdpOfferParams = __Infer<
|
||||
typeof SendScreenSdpOfferReducer
|
||||
>;
|
||||
export type SendVoiceIceCandidateParams = __Infer<
|
||||
typeof SendVoiceIceCandidateReducer
|
||||
>;
|
||||
export type SendVoiceSdpAnswerParams = __Infer<
|
||||
typeof SendVoiceSdpAnswerReducer
|
||||
>;
|
||||
export type SendVoiceSdpOfferParams = __Infer<typeof SendVoiceSdpOfferReducer>;
|
||||
export type SetNameParams = __Infer<typeof SetNameReducer>;
|
||||
export type SetSharingScreenParams = __Infer<typeof SetSharingScreenReducer>;
|
||||
export type SetTalkingParams = __Infer<typeof SetTalkingReducer>;
|
||||
export type StartWatchingParams = __Infer<typeof StartWatchingReducer>;
|
||||
export type StopWatchingParams = __Infer<typeof StopWatchingReducer>;
|
||||
|
||||
|
||||
+19
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
+4
-7
@@ -1,13 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import basicSsl from '@vitejs/plugin-basic-ssl';
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
basicSsl(),
|
||||
],
|
||||
plugins: [react(), basicSsl()],
|
||||
server: {
|
||||
https: true,
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom', // or "node" if you're not testing DOM
|
||||
setupFiles: './src/setupTests.ts',
|
||||
environment: "jsdom", // or "node" if you're not testing DOM
|
||||
setupFiles: "./src/setupTests.ts",
|
||||
testTimeout: 15_000, // give extra time for real connections
|
||||
hookTimeout: 15_000,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user