svelte migration

This commit is contained in:
2026-03-31 18:55:57 -04:00
parent 14f9fa241c
commit 16a690d291
69 changed files with 5102 additions and 8543 deletions
+2 -2
View File
@@ -794,8 +794,8 @@ index.ts → imports spacetimedb from ./schema, defines reducers
``` ```
src/module_bindings/ → Generated (spacetime generate) src/module_bindings/ → Generated (spacetime generate)
src/main.tsx → Provider, connection setup src/main.ts → Provider, connection setup
src/App.tsx → UI components src/App.svelte → UI components
src/config.ts → MODULE_NAME, SPACETIMEDB_URI src/config.ts → MODULE_NAME, SPACETIMEDB_URI
``` ```
+2 -2
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Vite + Svelte + TS</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+7 -12
View File
@@ -16,33 +16,28 @@
"spacetime:publish": "spacetime publish --module-path server --server maincloud" "spacetime:publish": "spacetime publish --module-path server --server maincloud"
}, },
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@testing-library/svelte": "^5.3.1",
"oidc-client-ts": "^3.5.0", "oidc-client-ts": "^3.5.0",
"react": "^18.3.1", "spacetimedb": "^2.1.0",
"react-dom": "^18.3.1", "svelte": "^5.55.1",
"react-oidc-context": "^3.3.1", "svelte-check": "^4.4.6"
"spacetimedb": "^2.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.18", "@types/node": "^25.5.0",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^8.57.2", "@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2", "@typescript-eslint/parser": "^8.57.2",
"@vitejs/plugin-basic-ssl": "^2.3.0", "@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0", "globals": "^15.14.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.18.2", "typescript-eslint": "^8.18.2",
"vite": "^7.1.5", "vite": "^8.0.3",
"vitest": "3.2.4" "vitest": "3.2.4"
} }
} }
+1578 -3870
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -576,7 +576,7 @@ body {
width: 100%; width: 100%;
} }
/* We'll add a row-container to wrap only the row items in VideoGrid.tsx */ /* We'll add a row-container to wrap only the row items in VideoGrid.svelte */
.video-participants-row { .video-participants-row {
display: flex; display: flex;
gap: 12px; gap: 12px;
-76
View File
@@ -1,76 +0,0 @@
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 () => {
const connectionBuilder = DbConnection.builder()
.withUri("ws://localhost:3000")
.withDatabaseName("quickstart-chat")
.withToken(
localStorage.getItem(
"ws://localhost:3000/quickstart-chat/auth_token",
) || "",
);
render(
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
<App />
</SpacetimeDBProvider>,
);
// Initially, we should see "Connecting..."
expect(screen.getByText(/Connecting.../i)).toBeInTheDocument();
// Wait until "Connecting..." is gone (meaning we've connected)
// This might require the actual DB to accept the connection
await waitFor(
() =>
expect(screen.queryByText(/Connecting.../i)).not.toBeInTheDocument(),
{ timeout: 10000 },
);
// The profile section should show the default name or truncated identity
// For example, you can check if the text is rendered.
// If your default identity is something like 'abcdef12' or 'Unknown'
// we do a generic check:
expect(
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 });
await userEvent.clear(nameInput);
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();
},
{ 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 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();
},
{ timeout: 10000 },
);
});
});
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import { onMount } from "svelte";
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
import { connectionBuilder } from "./config";
import { AuthGate } from "./auth";
import { ChatContainer } from "./chat";
import "./App.css";
// Initialize SpacetimeDB provider
// This sets up the context for useTable and useReducer
const db = createSpacetimeDBProvider(connectionBuilder());
$effect(() => {
console.log("App: SpacetimeDB State:", $db);
});
</script>
<AuthGate>
{#if $db.identity}
<ChatContainer />
{:else}
<div class="login-screen">
<h1>Connecting to SpacetimeDB...</h1>
</div>
{/if}
</AuthGate>
-22
View File
@@ -1,22 +0,0 @@
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';
// import { useSpacetimeDB, useTable, useReducer } from 'spacetimedb/react';
// import { Identity } from 'spacetimedb';
// import { useAuth } from "react-oidc-context";
// import { TOKEN_KEY } from './main';
// Import the new ChatContainer component
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 />;
}
export default App;
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { onMount } from "svelte";
import { auth } from "./auth.svelte";
import UsernamePasswordAuth from "./UsernamePasswordAuth.svelte";
import { TOKEN_KEY } from "../config";
let { children } = $props<{ children: any }>();
let authError = $state<string | null>(null);
let hasStoredToken = $state(false);
onMount(() => {
hasStoredToken = !!localStorage.getItem(TOKEN_KEY);
});
function handleUsernamePasswordLoginSuccess() {
console.log("Username/Password login successful. AuthGate will re-render.");
hasStoredToken = true;
}
function handleUsernamePasswordRegisterSuccess() {
console.log(
"Username/Password registration successful. AuthGate will re-render.",
);
hasStoredToken = true;
}
function handleAuthError(error: string | null) {
authError = error;
}
const isBypassEnabled =
import.meta.env.VITE_BYPASS_AUTH === "true" ||
new URLSearchParams(window.location.search).has("bypass_auth");
const isAuthenticated = $derived(auth.isAuthenticated || hasStoredToken || isBypassEnabled);
$effect(() => {
console.log("AuthGate: auth.isLoading:", auth.isLoading);
console.log("AuthGate: auth.isAuthenticated:", auth.isAuthenticated);
console.log("AuthGate: hasStoredToken:", hasStoredToken);
console.log("AuthGate: isBypassEnabled:", isBypassEnabled);
});
</script>
{#if auth.isLoading && !isBypassEnabled}
<div class="login-screen">
<h1>Loading Authentication...</h1>
</div>
{:else if isAuthenticated}
{@render children()}
{:else}
<div class="login-screen">
<div class="login-card">
<h1>Welcome!</h1>
<p>Please log in to continue to the chat.</p>
{#if authError}
<p style="color: #da373c; margin-bottom: 10px;">{authError}</p>
{/if}
<button
onclick={() => auth.signinRedirect()}
disabled={auth.isLoading}
class="btn-primary"
style="width: 100%; margin-bottom: 10px;"
>
{auth.isLoading ? "Loading..." : "Login with OIDC"}
</button>
<UsernamePasswordAuth
onLoginSuccess={handleUsernamePasswordLoginSuccess}
onRegisterSuccess={handleUsernamePasswordRegisterSuccess}
onError={handleAuthError}
/>
</div>
</div>
{/if}
-87
View File
@@ -1,87 +0,0 @@
// 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";
interface AuthGateProps {
children: React.ReactNode; // This will be SpacetimeDBWrapper
}
function AuthGate({ children }: AuthGateProps) {
const auth = useAuth();
const [authError, setAuthError] = useState<string | null>(null);
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);
const handleUsernamePasswordLoginSuccess = () => {
console.log("Username/Password login successful. AuthGate will re-render.");
setHasStoredToken(true);
};
const handleUsernamePasswordRegisterSuccess = () => {
console.log(
"Username/Password registration successful. AuthGate will re-render.",
);
setHasStoredToken(true);
};
const handleAuthError = (error: string | null) => {
setAuthError(error);
};
const isAuthenticated = auth.isAuthenticated || hasStoredToken;
if (auth.isLoading) {
// Show a loading indicator instead of a blank white screen
console.log("AuthGate: Authentication is loading...");
return (
<div className="login-screen">
<h1>Loading Authentication...</h1>
</div>
);
}
if (isAuthenticated) {
console.log("AuthGate: Authenticated. Rendering children.");
return <>{children}</>;
}
// If not authenticated, show login options
console.log("AuthGate: Not authenticated. Showing login options.");
return (
<div className="login-screen">
<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>
)}
<button
onClick={() => auth.signinRedirect()}
disabled={auth.isLoading}
className="btn-primary"
style={{ width: "100%", marginBottom: "10px" }}
>
{auth.isLoading ? "Loading..." : "Login with OIDC"}
</button>
<UsernamePasswordAuth
onLoginSuccess={handleUsernamePasswordLoginSuccess}
onRegisterSuccess={handleUsernamePasswordRegisterSuccess}
onError={handleAuthError}
/>
</div>
</div>
);
}
export default AuthGate;
-24
View File
@@ -1,24 +0,0 @@
// src/auth/OidcProvider.tsx
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",
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 {
children: ReactNode;
}
export function OidcProvider({ children }: OidcProviderProps) {
return <AuthProvider {...oidcConfig}>{children}</AuthProvider>;
}
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import { useSpacetimeDB } from "spacetimedb/svelte";
import { auth } from "./auth.svelte";
import { get } from "svelte/store";
let { onLoginSuccess, onRegisterSuccess, onError } = $props<{
onLoginSuccess: () => void;
onRegisterSuccess: () => void;
onError: (error: string | null) => void;
}>();
let username = $state("");
let password = $state("");
let isLoading = $state(false);
// Get the SpacetimeDB connection instance using the Svelte hook
const spacetime = useSpacetimeDB();
async function handleLogin() {
const conn = get(spacetime).getConnection();
if (!conn) {
onError("Database connection not available.");
return;
}
if (!username || !password) {
onError("Please enter both username and password.");
return;
}
isLoading = true;
onError(null);
try {
conn.reducers.login({ username, password });
onLoginSuccess();
} catch (e: any) {
onError(`Login error: ${e.message || "Unknown error"}`);
console.error("Login error:", e);
} finally {
isLoading = false;
}
}
async function handleRegister() {
const conn = get(spacetime).getConnection();
if (!conn) {
onError("Database connection not available.");
return;
}
if (!username || !password) {
onError("Please enter both username and password.");
return;
}
isLoading = true;
onError(null);
try {
conn.reducers.register({ username, password });
onRegisterSuccess();
} catch (e: any) {
onError(`Registration error: ${e.message || "Unknown error"}`);
console.error("Registration error:", e);
} finally {
isLoading = false;
}
}
</script>
<div
style="padding: 20px; border: 1px solid #ccc; border-radius: 8px; margin-bottom: 20px;"
>
<h3 style="margin-top: 0;">Username/Password Authentication</h3>
<form onsubmit={(e) => e.preventDefault()}>
<div style="margin-bottom: 10px;">
<input
id="username"
name="username"
type="text"
placeholder="Username"
bind:value={username}
style="padding: 8px; margin-right: 10px; border-radius: 4px; border: 1px solid #ccc;"
disabled={isLoading}
/>
</div>
<div style="margin-bottom: 10px;">
<input
id="password"
name="password"
type="password"
placeholder="Password"
bind:value={password}
style="padding: 8px; margin-right: 10px; border-radius: 4px; border: 1px solid #ccc;"
disabled={isLoading}
/>
</div>
<div style="display: flex; gap: 10px;">
<button
type="button"
onclick={handleLogin}
disabled={isLoading || !get(spacetime).isActive}
style="padding: 10px 15px; border-radius: 4px; cursor: {isLoading ||
!get(spacetime).isActive
? 'not-allowed'
: 'pointer'};"
>
{isLoading ? "Logging in..." : "Login"}
</button>
<button
type="button"
onclick={handleRegister}
disabled={isLoading || !get(spacetime).isActive}
style="padding: 10px 15px; border-radius: 4px; cursor: {isLoading ||
!get(spacetime).isActive
? 'not-allowed'
: 'pointer'};"
>
{isLoading ? "Registering..." : "Register"}
</button>
</div>
</form>
</div>
-148
View File
@@ -1,148 +0,0 @@
// 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
// Define the expected shape of the DbConnection instance from the hook
interface SpacetimeDBConnection {
call: <T>(reducerName: string, args: any[]) => Promise<T>;
// Add other DbConnection properties/methods if needed
}
interface UsernamePasswordAuthProps {
onLoginSuccess: () => void; // Callback on successful login
onRegisterSuccess: () => void; // Callback on successful registration
onError: (error: string | null) => void; // Callback for errors
}
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
const { conn } = useSpacetimeDB() as any;
const handleLogin = async () => {
if (!conn) {
onError("Database connection not available.");
return;
}
if (!username || !password) {
onError("Please enter both username and password.");
return;
}
setIsLoading(true);
onError(null); // Clear previous errors
try {
// Correct way to call reducers in SpacetimeDB TS SDK:
// conn.reducers.reducerName({ arg1: value1, ... })
conn.reducers.login({ username, password });
// Since reducers are asynchronous and don't return values to the caller,
// we assume success if no error is thrown immediately.
// 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);
} finally {
setIsLoading(false);
}
};
const handleRegister = async () => {
if (!conn) {
onError("Database connection not available.");
return;
}
if (!username || !password) {
onError("Please enter both username and password.");
return;
}
setIsLoading(true);
onError(null); // Clear previous errors
try {
conn.reducers.register({ username, password });
onRegisterSuccess();
} catch (e: any) {
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" }}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
style={{
padding: "8px",
marginRight: "10px",
borderRadius: "4px",
border: "1px solid #ccc",
}}
disabled={isLoading}
/>
</div>
<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",
}}
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>
<button
onClick={handleRegister}
disabled={isLoading || !conn}
style={{
padding: "10px 15px",
borderRadius: "4px",
cursor: isLoading || !conn ? "not-allowed" : "pointer",
}}
>
{isLoading ? "Registering..." : "Register"}
</button>
</div>
);
}
export default UsernamePasswordAuth;
+122
View File
@@ -0,0 +1,122 @@
import { UserManager, type User, type UserManagerSettings } from "oidc-client-ts";
// OIDC Configuration - User should replace these with their own provider values
export const oidcConfig: UserManagerSettings = {
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",
};
class AuthStore {
#userManager: UserManager;
#user = $state<User | null | undefined>(null);
#isLoading = $state(true);
constructor(settings: UserManagerSettings) {
this.#userManager = new UserManager(settings);
// Check if we are in a callback from the OIDC provider
if (
window.location.search.includes("code=") &&
window.location.search.includes("state=")
) {
this.signinCallback();
} else {
this.#userManager
.getUser()
.then((user) => {
this.#user = user;
})
.finally(() => {
this.#isLoading = false;
});
}
this.#userManager.events.addUserLoaded((user) => {
this.#user = user;
});
this.#userManager.events.addUserUnloaded(() => {
this.#user = null;
});
}
get user() {
return this.#user;
}
get isLoading() {
return this.#isLoading;
}
get isAuthenticated() {
return !!this.#user;
}
async signinRedirect() {
this.#isLoading = true;
try {
await this.#userManager.signinRedirect();
} catch (error) {
console.error("Signin redirect error:", error);
this.#isLoading = false;
}
}
async signinCallback() {
this.#isLoading = true;
try {
const user = await this.#userManager.signinCallback();
this.#user = user;
window.history.replaceState({}, document.title, window.location.pathname);
} catch (error) {
console.error("Signin callback error:", error);
} finally {
this.#isLoading = false;
}
}
async signoutRedirect() {
this.#isLoading = true;
try {
await this.#userManager.signoutRedirect();
} catch (error) {
console.error("Signout redirect error:", error);
} finally {
this.#isLoading = false;
}
}
async logout() {
this.#isLoading = true;
try {
// Clear all potential SpacetimeDB tokens from local storage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.includes("auth_token")) {
localStorage.removeItem(key);
}
}
if (this.#user) {
await this.#userManager.signoutRedirect();
} else {
// If not OIDC, just reload to clear state
window.location.reload();
}
} catch (error) {
console.error("Logout error:", error);
} finally {
this.#isLoading = false;
}
}
async clearStaleState() {
await this.#userManager.clearStaleState();
}
}
export const auth = new AuthStore(oidcConfig);
+3 -3
View File
@@ -1,4 +1,4 @@
// src/auth/index.ts // src/auth/index.ts
export { OidcProvider, oidcConfig } from "./OidcProvider"; export { auth } from "./auth.svelte";
export { default as AuthGate } from "./AuthGate"; export { default as AuthGate } from "./AuthGate.svelte";
export { default as UsernamePasswordAuth } from "./UsernamePasswordAuth"; export { default as UsernamePasswordAuth } from "./UsernamePasswordAuth.svelte";
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import { useSpacetimeDB } from "spacetimedb/svelte";
import { setContext } from "svelte";
import { ChatService } from "./services/chat.svelte";
import { WebRTCService } from "./services/webrtc/webrtc.svelte";
import ServerList from "./components/ServerList.svelte";
import ChannelList from "./components/ChannelList.svelte";
import MessageList from "./components/MessageList.svelte";
import MessageInput from "./components/MessageInput.svelte";
import MemberList from "./components/MemberList.svelte";
import ThreadView from "./components/ThreadView.svelte";
import ServerDiscovery from "./components/ServerDiscovery.svelte";
import VideoGrid from "./components/VideoGrid.svelte";
import SettingsPanel from "./components/SettingsPanel.svelte";
const spacetime = useSpacetimeDB();
// identity is guaranteed to be non-null here because of the guard in App.svelte
const identity = $derived($spacetime.identity!);
const chat = new ChatService(null);
const webrtc = new WebRTCService(null, undefined);
// Provide services via context
setContext("chat", chat);
setContext("webrtc", webrtc);
let showSettings = $state(false);
let showMemberList = $state(true);
$effect(() => {
chat.identity = identity;
webrtc.identity = identity;
webrtc.connectedChannelId = chat.connectedVoiceChannel?.id;
});
function handleGlobalClick() {
webrtc?.voice.peerManager.getAudioContext();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="chat-container" onclick={handleGlobalClick}>
<ServerList />
<div class="sidebar-container">
<ChannelList />
{#if chat.connectedVoiceChannel}
<div class="voice-status-bar">
<div class="voice-info">
<div class="voice-connected-text">
<span style="margin-right: 4px">📶</span>
Voice Connected
</div>
<div class="voice-channel-name">
{chat.connectedVoiceChannel.name} / {chat.activeServer?.name}
</div>
</div>
<div class="voice-actions">
<button
class="icon-btn"
onclick={chat.handleLeaveVoice}
title="Disconnect"
style="color: #f23f43"
>
<span style="font-size: 1.2rem"></span>
</button>
</div>
</div>
{/if}
<div class="user-info-bar">
<div class="user-info-main">
<div class="avatar small">
{chat.currentUser?.name?.[0]?.toUpperCase() ||
identity?.toHexString().substring(0, 2).toUpperCase()}
</div>
<div class="user-details">
<div class="user-display-name">
{chat.currentUser?.name || identity?.toHexString().substring(0, 8)}
</div>
<div class="user-status">
<div class="status-dot green" style="width: 8px; height: 8px;"></div>
Online
</div>
</div>
</div>
<div class="user-actions">
<button
class="icon-btn {webrtc.isMuted ? 'active' : ''}"
onclick={webrtc.toggleMute}
title={webrtc.isMuted ? "Unmute" : "Mute"}
>
{webrtc.isMuted ? "🎙️❌" : "🎙️"}
</button>
<button
class="icon-btn {webrtc.isDeafened ? 'active' : ''}"
onclick={webrtc.toggleDeafen}
title={webrtc.isDeafened ? "Undeafen" : "Deafen"}
>
{webrtc.isDeafened ? "🎧❌" : "🎧"}
</button>
<button
class="icon-btn"
onclick={() => (showSettings = true)}
title="User Settings"
>
⚙️
</button>
</div>
</div>
</div>
<div
class="main-content {showMemberList || chat.activeThreadId ? 'has-right-sidebar' : ''}"
>
<div class="chat-header">
<div style="display: flex; align-items: center; gap: 8px; flex: 1;">
<span class="channel-item-hash">
{chat.isActiveChannelVoice ? "🔊" : "#"}
</span>
{chat.activeChannel?.name || "Select a channel"}
</div>
<div style="display: flex; align-items: center; gap: 12px;">
{#if chat.isActiveChannelVoice && chat.connectedVoiceChannel?.id === chat.activeChannel?.id}
<button
class="screen-share-btn {webrtc.isSharingScreen ? 'active' : ''}"
onclick={webrtc.isSharingScreen ? webrtc.stopScreenShare : webrtc.startScreenShare}
>
{webrtc.isSharingScreen ? "Stop Sharing" : "Share Screen"}
</button>
{/if}
{#if !chat.activeThreadId}
<button
class="icon-btn {showMemberList ? 'active' : ''}"
onclick={() => (showMemberList = !showMemberList)}
title={showMemberList ? "Hide Member List" : "Show Member List"}
>
👥
</button>
{/if}
</div>
</div>
{#if chat.isActiveChannelVoice}
<VideoGrid />
{:else}
<MessageList />
<MessageInput />
{/if}
</div>
{#if chat.activeThreadId}
<ThreadView />
{:else if showMemberList}
<MemberList />
{/if}
{#if chat.showDiscoveryModal}
<ServerDiscovery />
{/if}
{#if showSettings}
<SettingsPanel
currentUser={chat.currentUser}
onClose={() => (showSettings = false)}
/>
{/if}
</div>
-277
View File
@@ -1,277 +0,0 @@
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();
const { identity } = useSpacetimeDB();
const [showSettings, setShowSettings] = useState(false);
const [showMemberList, setShowMemberList] = useState(true);
const {
peerStatuses,
peers,
localScreenStream,
startScreenShare,
stopScreenShare,
isSharingScreen,
startWatching,
stopWatching,
watching,
isMuted,
isDeafened,
toggleMute,
toggleDeafen,
peerStats,
setPeerAudioPreference,
} = useWebRTC(chat.connectedVoiceChannel?.id);
useEffect(() => {
console.log("ChatContainer mounted. Identity:", identity?.toHexString());
}, [identity]);
return (
<div className="chat-container">
<ServerList
joinedServers={chat.joinedServers}
activeServerId={chat.activeServerId}
setActiveServerId={chat.setActiveServerId}
isFullyAuthenticated={chat.isFullyAuthenticated}
identity={identity ?? null}
users={chat.users}
showCreateServerModal={chat.showCreateServerModal}
setShowCreateServerModal={chat.setShowCreateServerModal}
newServerName={chat.newServerName}
setNewServerName={chat.setNewServerName}
handleCreateServer={chat.handleCreateServer}
setShowDiscoveryModal={chat.setShowDiscoveryModal}
handleLeaveServer={chat.handleLeaveServer}
/>
<div className="sidebar-container">
<ChannelList
activeServerId={chat.activeServerId}
activeChannelId={chat.activeChannelId}
setActiveChannelId={chat.setActiveChannelId}
setActiveThreadId={chat.setActiveThreadId}
channels={chat.channels}
servers={chat.servers}
users={chat.users}
identity={identity ?? null}
voiceStates={chat.voiceStates}
currentVoiceState={chat.currentVoiceState}
connectedVoiceChannel={chat.connectedVoiceChannel}
isFullyAuthenticated={chat.isFullyAuthenticated}
showCreateChannelModal={chat.showCreateChannelModal}
setShowCreateChannelModal={chat.setShowCreateChannelModal}
newChannelName={chat.newChannelName}
setNewChannelName={chat.setNewChannelName}
isVoiceChannel={chat.isVoiceChannel}
setIsVoiceChannel={chat.setIsVoiceChannel}
handleCreateChannel={chat.handleCreateChannel}
handleJoinVoice={chat.handleJoinVoice}
handleLeaveVoice={chat.handleLeaveVoice}
handleLeaveServer={chat.handleLeaveServer}
peerStatuses={peerStatuses}
watching={watching}
peerStats={peerStats}
setPeerAudioPreference={setPeerAudioPreference}
voiceActivity={chat.voiceActivity}
/>
{/* Voice Connected Status Bar */}
{chat.connectedVoiceChannel && (
<div className="voice-status-bar">
<div className="voice-info">
<div className="voice-connected-text">
<span style={{ marginRight: "4px" }}>📶</span>
Voice Connected
</div>
<div className="voice-channel-name">
{chat.connectedVoiceChannel.name} / {chat.activeServer?.name}
</div>
</div>
<div className="voice-actions">
<button
className="icon-btn"
onClick={chat.handleLeaveVoice}
title="Disconnect"
style={{ color: "#f23f43" }}
>
<span style={{ fontSize: "1.2rem" }}></span>
</button>
</div>
</div>
)}
{/* User Info Bar */}
<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()}
</div>
<div className="user-details">
<div className="user-display-name">
{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" : ""}`}
onClick={toggleMute}
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? "🎙️❌" : "🎙️"}
</button>
<button
className={`icon-btn ${isDeafened ? "active" : ""}`}
onClick={toggleDeafen}
title={isDeafened ? "Undeafen" : "Deafen"}
>
{isDeafened ? "🎧❌" : "🎧"}
</button>
<button
className="icon-btn"
onClick={() => setShowSettings(true)}
title="User Settings"
>
</button>
</div>
</div>
</div>
<div
className={`main-content ${showMemberList || chat.activeThreadId ? "has-right-sidebar" : ""}`}
>
<div className="chat-header">
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
flex: 1,
}}
>
<span className="channel-item-hash">
{chat.isActiveChannelVoice ? "🔊" : "#"}
</span>
{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>
)}
{!chat.activeThreadId && (
<button
className={`icon-btn ${showMemberList ? "active" : ""}`}
onClick={() => setShowMemberList(!showMemberList)}
title={showMemberList ? "Hide Member List" : "Show Member List"}
>
👥
</button>
)}
</div>
</div>
{chat.isActiveChannelVoice ? (
<VideoGrid
peers={peers}
localScreenStream={localScreenStream}
connectedChannelId={chat.activeChannel!.id}
startWatching={startWatching}
stopWatching={stopWatching}
watching={watching}
users={chat.users}
voiceStates={chat.voiceStates}
voiceActivity={chat.voiceActivity}
/>
) : (
<>
<MessageList
messages={chat.channelMessages}
activeThreadId={chat.activeThreadId}
setActiveThreadId={chat.setActiveThreadId}
users={chat.users}
identity={identity ?? null}
handleStartThread={chat.handleStartThread}
isFullyAuthenticated={chat.isFullyAuthenticated}
allThreads={chat.allThreads}
/>
<MessageInput
activeChannelId={chat.activeChannelId}
activeThreadId={chat.activeThreadId}
isFullyAuthenticated={chat.isFullyAuthenticated}
sendMessageReducer={chat.sendMessageReducer}
/>
</>
)}
</div>
{chat.activeThreadId ? (
<ThreadView
activeThreadId={chat.activeThreadId}
setActiveThreadId={chat.setActiveThreadId}
activeChannelId={chat.activeChannelId}
activeServer={chat.activeServer}
isFullyAuthenticated={chat.isFullyAuthenticated}
users={chat.users}
identity={identity ?? null}
allThreads={chat.allThreads}
allMessages={chat.allMessages}
/>
) : (
showMemberList && (
<MemberList
activeServerMembers={chat.activeServerMembers}
users={chat.users}
identity={identity ?? null}
activeServer={chat.activeServer}
voiceStates={chat.voiceStates}
currentVoiceState={chat.currentVoiceState}
connectedVoiceChannel={chat.connectedVoiceChannel}
voiceActivity={chat.voiceActivity}
/>
)
)}
{chat.showDiscoveryModal && (
<ServerDiscovery
availableServers={chat.availableServers}
handleJoinServer={chat.handleJoinServer}
onClose={() => chat.setShowDiscoveryModal(false)}
isFullyAuthenticated={chat.isFullyAuthenticated}
/>
)}
{showSettings && (
<SettingsPanel
currentUser={chat.currentUser}
onClose={() => setShowSettings(false)}
/>
)}
</div>
);
};
export default ChatContainer;
+452
View File
@@ -0,0 +1,452 @@
<script lang="ts">
import { getContext, onMount } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import type { WebRTCService } from "../services/webrtc/webrtc.svelte";
import type { WebRTCStats } from "../services/webrtc/types";
import type * as Types from "../../module_bindings/types";
const chat = getContext<ChatService>("chat");
const webrtc = getContext<WebRTCService>("webrtc");
let hoveredPeer = $state<string | null>(null);
let showServerDropdown = $state(false);
let contextMenu = $state<{
peerIdHex: string;
name: string;
x: number;
y: number;
} | null>(null);
const getStatusColor = (
status: string | undefined,
): "green" | "yellow" | "red" => {
if (!status) return "yellow";
const [iceState] = status.toLowerCase().split("/");
if (iceState.includes("connected") || iceState.includes("completed"))
return "green";
if (
iceState.includes("connecting") ||
iceState.includes("checking") ||
iceState.includes("new")
)
return "yellow";
if (
iceState.includes("failed") ||
iceState.includes("disconnected") ||
iceState.includes("closed")
)
return "red";
return "yellow";
};
const formatBitrate = (bps: number) => {
if (bps > 1000000) return `${(bps / 1000000).toFixed(2)} Mbps`;
if (bps > 1000) return `${(bps / 1000).toFixed(1)} Kbps`;
return `${bps.toFixed(0)} bps`;
};
// Voice Context Menu state
let volume = $state(1.0);
let isMuted = $state(false);
function handleVolumeChange(e: Event, peerIdHex: string) {
const val = parseFloat((e.target as HTMLInputElement).value);
volume = val;
webrtc.setPeerAudioPreference(peerIdHex, { volume: val });
}
function toggleMute(peerIdHex: string) {
isMuted = !isMuted;
webrtc.setPeerAudioPreference(peerIdHex, { muted: isMuted });
}
onMount(() => {
const handleClick = () => {
contextMenu = null;
};
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
});
</script>
{#if !chat.activeServer}
<div class="channel-sidebar">
<div class="server-header">Select a Server</div>
</div>
{:else}
<div class="channel-sidebar">
<button
class="server-header clickable"
onclick={() => (showServerDropdown = !showServerDropdown)}
>
<h3
style="margin: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left;"
>
{chat.activeServer.name}
</h3>
<span style="font-size: 0.8rem; opacity: 0.6; margin-left: 4px;">
{showServerDropdown ? "▲" : "▼"}
</span>
</button>
{#if showServerDropdown}
<div class="server-dropdown">
<button
class="server-dropdown-item danger muted"
onclick={() => {
if (chat.activeServerId) {
chat.handleLeaveServer(chat.activeServerId);
showServerDropdown = false;
}
}}
>
Leave Server
</button>
</div>
{/if}
<div class="channel-section">
<div class="section-header">
<span>TEXT CHANNELS</span>
<button
class="add-btn"
onclick={() => {
chat.isVoiceChannel = false;
chat.showCreateChannelModal = true;
}}
>
+
</button>
</div>
{#each chat.textChannels as channel (channel.id.toString())}
<button
class="channel-item {chat.activeChannelId === channel.id ? 'active' : ''}"
onclick={() => {
chat.activeChannelId = channel.id;
chat.activeThreadId = null;
}}
>
<span class="channel-item-hash">#</span>
{channel.name}
</button>
{/each}
</div>
<div class="channel-section">
<div class="section-header">
<span>VOICE CHANNELS</span>
<button
class="add-btn"
onclick={() => {
chat.isVoiceChannel = true;
chat.showCreateChannelModal = true;
}}
>
+
</button>
</div>
{#each chat.voiceChannels as channel (channel.id.toString())}
<div>
<button
class="channel-item {chat.activeChannelId === channel.id ? 'active' : ''}"
onclick={() => {
chat.handleJoinVoice(channel.id);
chat.activeChannelId = channel.id;
chat.activeThreadId = null;
}}
style="cursor: {chat.isFullyAuthenticated ? 'pointer' : 'not-allowed'};"
>
<span class="channel-item-hash">🔊</span>
{channel.name}
</button>
<!-- Voice Channel Members -->
<div class="voice-member-list">
{#each chat.voiceStates.filter((vs) => vs.channelId === channel.id) as vs (vs.identity.toHexString())}
{@const peerIdHex = vs.identity.toHexString()}
{@const isMe = chat.identity?.isEqual(vs.identity)}
{@const status = webrtc.peerStatuses.get(peerIdHex)}
{@const isTalking = chat.voiceActivity.find((va) => va.identity.isEqual(vs.identity))?.isTalking || false}
{@const isSharing = vs.isSharingScreen}
{@const voiceStatusColor = isMe ? "green" : getStatusColor(status)}
{@const videoStatusColor = isMe ? (isSharing ? "green" : undefined) : (isSharing ? getStatusColor(status) : undefined)}
{@const isLocalUserInThisChannel = chat.connectedVoiceChannel?.id === channel.id}
<div
class="voice-member-item"
oncontextmenu={(e) => {
if (isMe) return;
e.preventDefault();
contextMenu = {
peerIdHex,
name: chat.getUsername(vs.identity),
x: e.clientX,
y: e.clientY,
};
}}
role="button"
tabindex="-1"
>
<div class="avatar tiny {isTalking ? 'talking' : ''}">
{chat.getUsername(vs.identity).substring(0, 2).toUpperCase()}
</div>
<span class="voice-member-name {isTalking ? 'talking' : ''}">
{chat.getUsername(vs.identity)}
</span>
{#if isSharing}
<span class="sharing-badge">LIVE</span>
{/if}
<div
class="voice-member-status-container"
onmouseenter={() => isLocalUserInThisChannel && (hoveredPeer = peerIdHex)}
onmouseleave={() => (hoveredPeer = null)}
role="presentation"
>
<div class="voice-member-indicators">
{#if vs.isDeafened}
<span class="voice-indicator-icon" title="Deafened">🎧❌</span>
{:else if vs.isMuted}
<span class="voice-indicator-icon" title="Muted">🎙️❌</span>
{/if}
</div>
{#if isLocalUserInThisChannel}
<div class="status-dot {voiceStatusColor}"></div>
{/if}
</div>
{#if isLocalUserInThisChannel && hoveredPeer === peerIdHex}
{@const stats = webrtc.peerStats.get(peerIdHex)}
<div class="connection-popover">
<div class="popover-header">
<span class="popover-name">{chat.getUsername(vs.identity)}</span>
<span class="popover-status {voiceStatusColor}">
{isMe ? "connected" : status || "connecting"}
</span>
</div>
{#if isMe}
<div class="popover-info">Local connection (sending only)</div>
{:else if stats}
<div class="popover-stats">
<div class="stats-section">
<div class="section-title">AUDIO</div>
<div class="stat-row">
<span>Bitrate</span>
<span>{formatBitrate(stats.audio.bitrate)}</span>
</div>
<div class="stat-row">
<span>Jitter</span>
<span>{(stats.audio.jitter * 1000).toFixed(2)} ms</span>
</div>
<div class="stat-row">
<span>Loss</span>
<span>{stats.audio.packetsLost} pkts</span>
</div>
</div>
{#if stats.video.bitrate > 0}
<div class="stats-section">
<div class="section-title">VIDEO</div>
<div class="stat-row">
<span>Bitrate</span>
<span>{formatBitrate(stats.video.bitrate)}</span>
</div>
<div class="stat-row">
<span>Res</span>
<span>{stats.video.frameWidth}x{stats.video.frameHeight}</span>
</div>
<div class="stat-row">
<span>FPS</span>
<span>{stats.video.framesPerSecond.toFixed(0)}</span>
</div>
</div>
{/if}
</div>
{:else}
<div class="popover-info">Waiting for statistics...</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
{#if chat.showCreateChannelModal}
<div
class="modal-overlay"
onclick={() => (chat.showCreateChannelModal = false)}
role="button"
tabindex="-1"
onkeydown={(e) => e.key === 'Escape' && (chat.showCreateChannelModal = false)}
>
<form
class="modal-content"
onclick={(e) => e.stopPropagation()}
onsubmit={chat.handleCreateChannel}
>
<h2>Create {chat.isVoiceChannel ? "Voice" : "Text"} Channel</h2>
<div class="settings-section">
<label
for="channel-name"
style="display: block; margin-bottom: 8px; font-size: 0.75rem; font-weight: bold; color: var(--text-muted); text-transform: uppercase;"
>
Channel Name
</label>
<input
id="channel-name"
name="channel-name"
type="text"
autofocus
placeholder="Enter channel name"
bind:value={chat.newChannelName}
style="width: 100%; box-sizing: border-box;"
/>
</div>
<div class="modal-actions">
<button
type="button"
class="icon-btn"
style="padding: 8px 16px; font-size: 0.9rem;"
onclick={() => (chat.showCreateChannelModal = false)}
>
Cancel
</button>
<button
type="submit"
class="btn-primary"
disabled={!chat.isFullyAuthenticated}
>
Create Channel
</button>
</div>
</form>
</div>
{/if}
{#if contextMenu}
<div
class="context-menu"
style="top: {contextMenu.y}px; left: {contextMenu.x}px;"
onclick={(e) => e.stopPropagation()}
role="presentation"
>
<div class="context-menu-header">{contextMenu.name}</div>
<button class="context-menu-item" onclick={() => toggleMute(contextMenu!.peerIdHex)}>
<span>{isMuted ? "Unmute" : "Mute"}</span>
<span class="context-menu-icon">{isMuted ? "🔈" : "🔊"}</span>
</button>
<div class="context-menu-section">
<label for="volume-slider">User Volume</label>
<input
id="volume-slider"
type="range"
min="0"
max="2"
step="0.01"
value={volume}
oninput={(e) => handleVolumeChange(e, contextMenu!.peerIdHex)}
class="volume-slider"
/>
<div style="font-size: 0.7rem; text-align: right; margin-top: 4px;">
{Math.round(volume * 100)}%
</div>
</div>
</div>
{/if}
</div>
{/if}
<style>
.channel-sidebar {
background-color: var(--background-secondary);
display: flex;
flex-direction: column;
width: 240px;
}
.server-header {
padding: 12px 16px;
height: 48px;
box-sizing: border-box;
display: flex;
align-items: center;
border-bottom: 1px solid var(--background-tertiary);
background: none;
border-left: none;
border-right: none;
border-top: none;
color: var(--text-normal);
cursor: pointer;
font-weight: bold;
width: 100%;
}
.server-header:hover {
background-color: var(--background-modifier-hover);
}
.channel-item {
display: flex;
align-items: center;
padding: 6px 8px;
margin: 2px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--channels-default);
background: none;
border: none;
width: calc(100% - 16px);
text-align: left;
font-size: 1rem;
}
.channel-item:hover {
background-color: var(--background-modifier-hover);
color: var(--interactive-hover);
}
.channel-item.active {
background-color: var(--background-modifier-selected);
color: var(--interactive-active);
}
.server-dropdown-item {
background: none;
border: none;
color: var(--text-normal);
width: 100%;
text-align: left;
padding: 8px;
cursor: pointer;
}
.server-dropdown-item:hover {
background-color: var(--brand);
color: white;
}
.server-dropdown-item.danger:hover {
background-color: var(--status-danger);
}
.context-menu-item {
background: none;
border: none;
color: var(--text-normal);
width: 100%;
text-align: left;
padding: 8px;
display: flex;
justify-content: space-between;
cursor: pointer;
}
.context-menu-item:hover {
background-color: var(--brand);
color: white;
}
</style>
-542
View File
@@ -1,542 +0,0 @@
import React from "react";
import { Identity } from "spacetimedb";
import { useSpacetimeDB } from "spacetimedb/react";
import * as Types from "../../module_bindings/types";
import { WebRTCStats } from "../services";
interface ChannelListProps {
activeServerId: bigint | null;
activeChannelId: bigint | null;
setActiveChannelId: (channelId: bigint | null) => void;
setActiveThreadId: (threadId: bigint | null) => void;
channels: readonly Types.Channel[];
servers: readonly Types.Server[];
users: readonly Types.User[];
identity: Identity | null;
voiceStates: readonly Types.VoiceState[];
currentVoiceState: Types.VoiceState | undefined;
connectedVoiceChannel: Types.Channel | undefined;
isFullyAuthenticated: boolean;
voiceActivity: readonly Types.VoiceActivity[];
// Modal state and handlers
showCreateChannelModal: boolean;
setShowCreateChannelModal: (show: boolean) => void;
newChannelName: string;
setNewChannelName: (name: string) => void;
isVoiceChannel: boolean;
setIsVoiceChannel: (isVoice: boolean) => void;
handleCreateChannel: (e: React.FormEvent) => void;
handleJoinVoice: (channelId: bigint) => void;
handleLeaveVoice: () => void;
handleLeaveServer: (serverId: bigint) => void;
peerStatuses: Map<string, string>;
watching: readonly Types.Watching[];
peerStats: Map<string, WebRTCStats>;
setPeerAudioPreference: (
peerIdHex: string,
preference: { volume?: number; muted?: boolean },
) => void;
}
const VoiceContextMenu: React.FC<{
peerIdHex: string;
name: string;
onClose: () => void;
setPreference: (
peerIdHex: string,
preference: { volume?: number; muted?: boolean },
) => void;
x: number;
y: number;
}> = ({ peerIdHex, name, onClose, setPreference, x, y }) => {
const [volume, setVolume] = React.useState(1.0);
const [isMuted, setIsMuted] = React.useState(false);
// We don't have a way to easily fetch current preference from usePeerManager here
// without more complex plumbing, so we'll just start at defaults or let it be.
// In a real app, we might store this in a context or global state.
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
setPreference(peerIdHex, { volume: val });
};
const toggleMute = () => {
const next = !isMuted;
setIsMuted(next);
setPreference(peerIdHex, { muted: next });
};
React.useEffect(() => {
const handleClick = () => onClose();
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
}, [onClose]);
return (
<div
className="context-menu"
style={{ top: y, left: x }}
onClick={(e) => e.stopPropagation()}
>
<div className="context-menu-header">{name}</div>
<div className="context-menu-item" onClick={toggleMute}>
<span>{isMuted ? "Unmute" : "Mute"}</span>
<span className="context-menu-icon">{isMuted ? "🔈" : "🔊"}</span>
</div>
<div className="context-menu-section">
<label>User Volume</label>
<input
type="range"
min="0"
max="2"
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
<div
style={{ fontSize: "0.7rem", textAlign: "right", marginTop: "4px" }}
>
{Math.round(volume * 100)}%
</div>
</div>
</div>
);
};
// Helper function (extracted from App.tsx)
const getUsername = (
userIdentity: Identity | null,
users: readonly Types.User[],
) => {
if (!userIdentity) return "Unknown";
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 formatBitrate = (bps: number) => {
if (bps > 1000000) return `${(bps / 1000000).toFixed(2)} Mbps`;
if (bps > 1000) return `${(bps / 1000).toFixed(1)} Kbps`;
return `${bps.toFixed(0)} bps`;
};
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>
</div>
{isMe ? (
<div className="popover-info">Local connection (sending only)</div>
) : stats ? (
<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>
{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>
)}
</div>
) : (
<div className="popover-info">Waiting for statistics...</div>
)}
</div>
);
};
export const ChannelList: React.FC<ChannelListProps> = ({
activeServerId,
activeChannelId,
setActiveChannelId,
setActiveThreadId,
channels,
servers,
users,
identity,
voiceStates,
currentVoiceState,
connectedVoiceChannel,
isFullyAuthenticated,
voiceActivity,
showCreateChannelModal,
setShowCreateChannelModal,
newChannelName,
setNewChannelName,
isVoiceChannel,
setIsVoiceChannel,
handleCreateChannel,
handleJoinVoice,
handleLeaveVoice,
handleLeaveServer,
peerStatuses,
watching,
peerStats,
setPeerAudioPreference,
}) => {
const [hoveredPeer, setHoveredPeer] = React.useState<string | null>(null);
const [showServerDropdown, setShowServerDropdown] = React.useState(false);
const [contextMenu, setContextMenu] = React.useState<{
peerIdHex: string;
name: string;
x: number;
y: number;
} | null>(null);
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 voiceChannels = React.useMemo(
() =>
channels.filter(
(c) => c.serverId === activeServerId && c.kind.tag === "Voice",
),
[channels, activeServerId],
);
if (!activeServer) {
return (
<div className="channel-sidebar">
<div className="server-header">Select a Server</div>
</div>
);
}
return (
<div className="channel-sidebar">
<div
className="server-header clickable"
onClick={() => setShowServerDropdown(!showServerDropdown)}
>
<h3
style={{
margin: 0,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{activeServer.name}
</h3>
<span style={{ fontSize: "0.8rem", opacity: 0.6, marginLeft: "4px" }}>
{showServerDropdown ? "▲" : "▼"}
</span>
</div>
{showServerDropdown && (
<div className="server-dropdown">
<div
className="server-dropdown-item danger muted"
onClick={() => {
if (activeServerId) {
handleLeaveServer(activeServerId);
setShowServerDropdown(false);
}
}}
>
Leave Server
</div>
</div>
)}
<div className="channel-section">
<div className="section-header">
<span>TEXT CHANNELS</span>
<button
className="add-btn"
onClick={() => {
setIsVoiceChannel(false);
setShowCreateChannelModal(true);
}}
>
+
</button>
</div>
{textChannels.map((channel) => (
<div
key={channel.id.toString()}
className={`channel-item ${activeChannelId === channel.id ? "active" : ""}`}
onClick={() => {
setActiveChannelId(channel.id);
setActiveThreadId(null);
}}
>
<span className="channel-item-hash">#</span>
{channel.name}
</div>
))}
</div>
<div className="channel-section">
<div className="section-header">
<span>VOICE CHANNELS</span>
<button
className="add-btn"
onClick={() => {
setIsVoiceChannel(true);
setShowCreateChannelModal(true);
}}
>
+
</button>
</div>
{voiceChannels.map((channel) => (
<div key={channel.id.toString()}>
<div
className={`channel-item ${activeChannelId === channel.id ? "active" : ""}`}
onClick={() => {
handleJoinVoice(channel.id);
setActiveChannelId(channel.id);
setActiveThreadId(null);
}}
style={{
cursor: isFullyAuthenticated ? "pointer" : "not-allowed",
}}
>
<span className="channel-item-hash">🔊</span>
{channel.name}
</div>
{/* Voice Channel Members */}
<div className="voice-member-list">
{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 isTalking =
voiceActivity.find((va) => va.identity.isEqual(vs.identity))
?.isTalking || false;
const isSharing = vs.isSharingScreen;
const voiceStatusColor = isMe
? "green"
: getStatusColor(status);
const videoStatusColor = isMe
? isSharing
? "green"
: undefined
: isSharing
? getStatusColor(status)
: undefined;
let finalStatusColor: "green" | "yellow" | "red" =
voiceStatusColor;
if (videoStatusColor === "red") finalStatusColor = "red";
else if (
videoStatusColor === "yellow" &&
finalStatusColor === "green"
)
finalStatusColor = "yellow";
const isLocalUserInThisChannel =
currentVoiceState?.channelId === channel.id;
return (
<div
key={peerIdHex}
className="voice-member-item"
onContextMenu={(e) => {
if (isMe) return; // Can't adjust own volume here
e.preventDefault();
setContextMenu({
peerIdHex,
name: getUsername(vs.identity, users),
x: e.clientX,
y: e.clientY,
});
}}
>
<div
className={`avatar tiny ${isTalking ? "talking" : ""}`}
>
{getUsername(vs.identity, users)
.substring(0, 2)
.toUpperCase()}
</div>
<span
className={`voice-member-name ${isTalking ? "talking" : ""}`}
>
{getUsername(vs.identity, users)}
</span>
{isSharing && <span className="sharing-badge">LIVE</span>}
<div
className="voice-member-status-container"
onMouseEnter={() =>
isLocalUserInThisChannel && setHoveredPeer(peerIdHex)
}
onMouseLeave={() => setHoveredPeer(null)}
>
<div className="voice-member-indicators">
{vs.isDeafened && (
<span
className="voice-indicator-icon"
title="Deafened"
>
🎧
</span>
)}
{!vs.isDeafened && vs.isMuted && (
<span
className="voice-indicator-icon"
title="Muted"
>
🎙
</span>
)}
</div>
{isLocalUserInThisChannel && (
<div className={`status-dot ${finalStatusColor}`} />
)}
</div>
{isLocalUserInThisChannel &&
hoveredPeer === peerIdHex && (
<ConnectionPopover
stats={peerStats.get(peerIdHex)}
status={isMe ? "connected" : status || "connecting"}
name={getUsername(vs.identity, users)}
isMe={!!isMe}
/>
)}
</div>
);
})}
</div>
</div>
))}
</div>
{showCreateChannelModal && (
<div
className="modal-overlay"
onClick={() => setShowCreateChannelModal(false)}
>
<form
className="modal-content"
onClick={(e) => e.stopPropagation()}
onSubmit={handleCreateChannel}
>
<h2>Create {isVoiceChannel ? "Voice" : "Text"} Channel</h2>
<div className="settings-section">
<label
style={{
display: "block",
marginBottom: "8px",
fontSize: "0.75rem",
fontWeight: "bold",
color: "var(--text-muted)",
textTransform: "uppercase",
}}
>
Channel Name
</label>
<input
autoFocus
placeholder="Enter channel name"
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
<div className="modal-actions">
<button
type="button"
className="icon-btn"
style={{ padding: "8px 16px", fontSize: "0.9rem" }}
onClick={() => setShowCreateChannelModal(false)}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={!isFullyAuthenticated}
>
Create Channel
</button>
</div>
</form>
</div>
)}
{contextMenu && (
<VoiceContextMenu
peerIdHex={contextMenu.peerIdHex}
name={contextMenu.name}
x={contextMenu.x}
y={contextMenu.y}
onClose={() => setContextMenu(null)}
setPreference={setPeerAudioPreference}
/>
)}
</div>
);
};
export default ChannelList;
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import type * as Types from "../../module_bindings/types";
const chat = getContext<ChatService>("chat");
let onlineMembers = $derived(chat.activeServerMembers.filter((m) => m.online));
let offlineMembers = $derived(chat.activeServerMembers.filter((m) => !m.online));
function isTalking(user: Types.User) {
return chat.voiceActivity.find((va) => va.identity.isEqual(user.identity))?.isTalking || false;
}
function isSharing(user: Types.User) {
const userVoiceState = chat.voiceStates.find((vs) => vs.identity.isEqual(user.identity));
return userVoiceState?.isSharingScreen || false;
}
function isMe(user: Types.User) {
return chat.identity?.isEqual(user.identity);
}
</script>
<div class="right-sidebar">
<div class="member-list">
{#if onlineMembers.length > 0}
<div class="member-list-section-header">
ONLINE — {onlineMembers.length}
</div>
{#each onlineMembers as user (user.identity.toHexString())}
<div class="member-item">
<div class="avatar tiny {isTalking(user) ? 'talking' : ''}">
{(user.name || user.identity.toHexString()).substring(0, 2).toUpperCase()}
</div>
<span class="member-name {isTalking(user) ? 'talking' : ''}">
{user.name || user.identity.toHexString().substring(0, 8)}
{#if isMe(user)}
<span class="me-badge">(You)</span>
{/if}
</span>
<div style="flex: 1;"></div>
{#if isSharing(user)}
<span class="sharing-badge">LIVE</span>
{/if}
<div class="status-dot green"></div>
</div>
{/each}
{/if}
{#if offlineMembers.length > 0}
<div class="member-list-section-header">
OFFLINE — {offlineMembers.length}
</div>
{#each offlineMembers as user (user.identity.toHexString())}
<div class="member-item offline">
<div class="avatar tiny">
{(user.name || user.identity.toHexString()).substring(0, 2).toUpperCase()}
</div>
<span class="member-name">
{user.name || user.identity.toHexString().substring(0, 8)}
{#if isMe(user)}
<span class="me-badge">(You)</span>
{/if}
</span>
<div style="flex: 1;"></div>
<div class="status-dot grey"></div>
</div>
{/each}
{/if}
</div>
</div>
<style>
.me-badge {
font-size: 0.7rem;
opacity: 0.6;
margin-left: 4px;
}
</style>
-105
View File
@@ -1,105 +0,0 @@
// src/chat/components/MemberList.tsx
import React, { useMemo } from "react";
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
// Helper function (extracted from App.tsx)
const getUsername = (
userIdentity: Identity | null,
users: readonly Types.User[],
) => {
if (!userIdentity) return "Unknown";
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
interface MemberListProps {
activeServerMembers: readonly Types.User[];
users: readonly Types.User[];
identity: Identity | null;
activeServer: Types.Server | undefined;
voiceStates: readonly Types.VoiceState[];
currentVoiceState: Types.VoiceState | undefined;
connectedVoiceChannel: Types.Channel | undefined;
voiceActivity: readonly Types.VoiceActivity[];
}
function MemberList({
activeServerMembers,
users,
identity,
activeServer,
voiceStates,
currentVoiceState,
connectedVoiceChannel,
voiceActivity,
}: MemberListProps) {
// Categorize members into Online and Offline
const onlineMembers = 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 isTalking =
voiceActivity.find((va) => va.identity.isEqual(user.identity))
?.isTalking || false;
const isSharing = userVoiceState?.isSharingScreen || false;
const isMe = identity?.isEqual(user.identity);
return (
<div
key={user.identity.toHexString()}
className={`member-item ${isOffline ? "offline" : ""}`}
>
<div
className={`avatar tiny ${isTalking && !isOffline ? "talking" : ""}`}
>
{(user.name || user.identity.toHexString())
.substring(0, 2)
.toUpperCase()}
</div>
<span
className={`member-name ${isTalking && !isOffline ? "talking" : ""}`}
>
{user.name || user.identity.toHexString().substring(0, 8)}
{isMe && <span className="me-badge">(You)</span>}
</span>
{isSharing && !isOffline && <span className="sharing-badge">LIVE</span>}
</div>
);
};
return (
<div className="right-sidebar">
<div className="member-list">
{onlineMembers.length > 0 && (
<>
<div className="member-list-section-header">
ONLINE {onlineMembers.length}
</div>
{onlineMembers.map((user) => renderMember(user))}
</>
)}
{offlineMembers.length > 0 && (
<>
<div className="member-list-section-header">
OFFLINE {offlineMembers.length}
</div>
{offlineMembers.map((user) => renderMember(user, true))}
</>
)}
</div>
</div>
);
}
export default MemberList;
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
let {
activeChannelId,
activeThreadId,
isFullyAuthenticated,
sendMessageReducer
} = $props<{
activeChannelId: bigint | null;
activeThreadId: bigint | null;
isFullyAuthenticated: boolean;
sendMessageReducer: (args: any) => void;
}>();
let messageText = $state("");
function handleSubmit(e: Event) {
e.preventDefault();
if (!messageText.trim() || !activeChannelId) return;
// Call the sendMessage reducer
sendMessageReducer({
text: messageText,
channelId: activeChannelId,
threadId: activeThreadId,
});
messageText = "";
}
</script>
<div class="chat-input-container">
<form class="chat-input" onsubmit={handleSubmit}>
<input
id="chat-message"
name="chat-message"
type="text"
placeholder={
isFullyAuthenticated
? `Message ${activeThreadId ? "in thread..." : "#channel"}`
: "Log in to chat"
}
disabled={!isFullyAuthenticated || !activeChannelId}
bind:value={messageText}
/>
</form>
</div>
-50
View File
@@ -1,50 +0,0 @@
// src/chat/components/MessageInput.tsx
import React, { useState } from "react";
interface MessageInputProps {
activeChannelId: bigint | null;
activeThreadId: bigint | null;
isFullyAuthenticated: boolean;
sendMessageReducer: (args: any) => void;
}
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("");
};
return (
<div className="chat-input-container">
<form className="chat-input" onSubmit={handleSubmit}>
<input
placeholder={
isFullyAuthenticated
? `Message ${activeThreadId ? "in thread..." : "#channel"}`
: "Log in to chat"
}
disabled={!isFullyAuthenticated || !activeChannelId}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
/>
</form>
</div>
);
}
export default MessageInput;
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import { getContext, tick } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import RichText from "./RichText.svelte";
const chat = getContext<ChatService>("chat");
let messagesEndRef = $state<HTMLDivElement | null>(null);
$effect(() => {
if (chat.channelMessages.length > 0) {
tick().then(() => {
messagesEndRef?.scrollIntoView({ behavior: "smooth" });
});
}
});
</script>
<div class="message-list">
{#each chat.channelMessages as msg (msg.id.toString())}
{@const msgUsername = chat.getUsername(msg.sender)}
{@const existingThread = chat.allThreads.find((t) => t.parentMessageId === msg.id)}
<div class="message-item">
<div class="avatar message-avatar">
{msgUsername.substring(0, 2).toUpperCase()}
</div>
<div class="message-content">
<div class="message-header">
<span class="user-name">{msgUsername}</span>
<span class="message-time">{chat.formatTime(msg.sent)}</span>
{#if !existingThread && chat.isFullyAuthenticated}
<button
class="start-thread-icon-btn"
onclick={() => chat.handleStartThread(msg)}
title="Start Thread"
style="background: none; border: none; cursor: pointer; font-size: 0.9rem; opacity: 0.6; margin-left: 8px; padding: 2px 4px; border-radius: 4px;"
>
💬
</button>
{/if}
</div>
<div class="message-text">
<RichText text={msg.text} />
</div>
{#if existingThread}
<button
class="thread-link"
onclick={() => (chat.activeThreadId = existingThread.id)}
style="margin-top: 4px; padding-left: 12px; border-left: 2px solid var(--background-accent); display: flex; align-items: center; gap: 4px; font-size: 0.85rem; background: none; border-top: none; border-right: none; border-bottom: none; color: var(--text-link); cursor: pointer;"
>
<span style="font-size: 0.8rem;"></span>
View Thread ({existingThread.name})
</button>
{/if}
</div>
</div>
{/each}
<div bind:this={messagesEndRef}></div>
</div>
<style>
.thread-link:hover {
text-decoration: underline;
}
</style>
-119
View File
@@ -1,119 +0,0 @@
// 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 RichText from "./RichText";
// Helper function (extracted from App.tsx)
const getUsername = (
userIdentity: Identity | null,
users: readonly Types.User[],
) => {
if (!userIdentity) return "Unknown";
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" });
};
interface MessageListProps {
messages: readonly Types.Message[];
activeThreadId: bigint | null;
setActiveThreadId: (threadId: bigint | null) => void;
users: readonly Types.User[];
identity: Identity | null;
handleStartThread: (msg: Types.Message) => void;
isFullyAuthenticated: boolean;
allThreads: readonly Types.Thread[];
}
function MessageList({
messages,
activeThreadId,
setActiveThreadId,
users,
identity,
handleStartThread,
isFullyAuthenticated,
allThreads,
}: MessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<div className="message-list">
{messages.map((msg) => {
const msgUsername = getUsername(msg.sender, users);
const existingThread = allThreads.find(
(t) => t.parentMessageId === msg.id,
);
return (
<div key={msg.id.toString()} className="message-item">
<div className="avatar message-avatar">
{msgUsername.substring(0, 2).toUpperCase()}
</div>
<div className="message-content">
<div className="message-header">
<span className="user-name">{msgUsername}</span>
<span className="message-time">{formatTime(msg.sent)}</span>
{!existingThread && isFullyAuthenticated && (
<button
className="start-thread-icon-btn"
onClick={() => handleStartThread(msg)}
title="Start Thread"
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.9rem",
opacity: 0.6,
marginLeft: "8px",
padding: "2px 4px",
borderRadius: "4px",
}}
>
💬
</button>
)}
</div>
<div className="message-text">
<RichText text={msg.text} />
</div>
{existingThread && (
<div
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",
}}
>
<span style={{ fontSize: "0.8rem" }}></span>
View Thread ({existingThread.name})
</div>
)}
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
);
}
export default MessageList;
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
let { text } = $props<{ text: string }>();
const isImageUrl = (url: string) => {
return url.match(/\.(jpeg|jpg|gif|png|webp|svg)$/i) != null;
};
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = $derived(text.split(urlRegex));
function onImageLoad(e: Event) {
const img = e.currentTarget as HTMLImageElement;
const list = img.closest(".message-list");
if (list) {
list.scrollTop = list.scrollHeight;
}
}
function onImageClick(url: string) {
window.open(url, "_blank");
}
</script>
<div class="rich-text">
{#each parts as part}
{#if part.match(urlRegex)}
<a
href={part}
target="_blank"
rel="noopener noreferrer"
class="url-link"
>
{part}
</a>
{#if isImageUrl(part)}
<div class="message-image-container">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img
src={part}
alt="attachment"
class="message-image"
onload={onImageLoad}
onclick={() => onImageClick(part)}
/>
</div>
{/if}
{:else}
<span style="white-space: pre-wrap;">{part}</span>
{/if}
{/each}
</div>
-59
View File
@@ -1,59 +0,0 @@
import React from "react";
interface RichTextProps {
text: string;
}
const isImageUrl = (url: string) => {
return url.match(/\.(jpeg|jpg|gif|png|webp|svg)$/i) != null;
};
const RichText: React.FC<RichTextProps> = ({ text }) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
return (
<div className="rich-text">
{parts.map((part, i) => {
if (part.match(urlRegex)) {
return (
<React.Fragment key={i}>
<a
href={part}
target="_blank"
rel="noopener noreferrer"
className="url-link"
>
{part}
</a>
{isImageUrl(part) && (
<div className="message-image-container">
<img
src={part}
alt="attachment"
className="message-image"
onLoad={(e) => {
// Trigger scroll to bottom when image loads by finding closest scrollable list
const list = e.currentTarget.closest(".message-list");
if (list) {
list.scrollTop = list.scrollHeight;
}
}}
onClick={() => window.open(part, "_blank")}
/>
</div>
)}
</React.Fragment>
);
}
return (
<span key={i} style={{ whiteSpace: "pre-wrap" }}>
{part}
</span>
);
})}
</div>
);
};
export default RichText;
@@ -0,0 +1,79 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ChatService } from "../services/chat.svelte";
const chat = getContext<ChatService>("chat");
let searchTerm = $state("");
let filteredServers = $derived(
chat.availableServers.filter((s) =>
s.name.toLowerCase().includes(searchTerm.toLowerCase())
)
);
</script>
<div class="modal-overlay" onclick={() => (chat.showDiscoveryModal = false)} role="button" tabindex="-1" onkeydown={(e) => e.key === 'Escape' && (chat.showDiscoveryModal = false)}>
<div
class="modal-content"
onclick={(e) => e.stopPropagation()}
style="width: 500px; max-height: 80vh; display: flex; flexDirection: column;"
role="presentation"
>
<div
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"
>
<h2 style="margin: 0;">Discover Servers</h2>
<button
type="button"
class="close-btn"
onclick={() => (chat.showDiscoveryModal = false)}
>
×
</button>
</div>
<input
id="server-search"
name="server-search"
type="text"
autofocus
placeholder="Search for servers..."
bind:value={searchTerm}
style="margin-bottom: 16px; width: 100%; box-sizing: border-box;"
/>
<div
style="overflow-y: auto; flex-grow: 1; display: flex; flex-direction: column; gap: 8px;"
>
{#if filteredServers.length === 0}
<p style="text-align: center; color: var(--text-muted);">
No servers found.
</p>
{:else}
{#each filteredServers as server (server.id.toString())}
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 12px; background-color: var(--background-secondary); border-radius: 8px;"
>
<div style="display: flex; align-items: center; gap: 12px;">
<div
class="server-icon"
style="width: 40px; height: 40px; font-size: 0.9rem;"
>
{server.name.substring(0, 2).toUpperCase()}
</div>
<span style="font-weight: bold;">{server.name}</span>
</div>
<button
class="btn-primary"
onclick={() => chat.handleJoinServer(server.id)}
disabled={!chat.isFullyAuthenticated}
>
Join
</button>
</div>
{/each}
{/if}
</div>
</div>
</div>
-115
View File
@@ -1,115 +0,0 @@
import React, { useState, useMemo } from "react";
import type * as Types from "../../module_bindings/types";
interface ServerDiscoveryProps {
availableServers: readonly Types.Server[];
handleJoinServer: (serverId: bigint) => void;
onClose: () => void;
isFullyAuthenticated: boolean;
}
function ServerDiscovery({
availableServers,
handleJoinServer,
onClose,
isFullyAuthenticated,
}: ServerDiscoveryProps) {
const [searchTerm, setSearchTerm] = useState("");
const filteredServers = useMemo(() => {
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",
}}
>
<h2 style={{ margin: 0 }}>Discover Servers</h2>
<button type="button" className="close-btn" onClick={onClose}>
×
</button>
</div>
<input
autoFocus
placeholder="Search for servers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ marginBottom: "16px" }}
/>
<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>
) : (
filteredServers.map((server) => (
<div
key={server.id.toString()}
style={{
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",
}}
>
{server.name.substring(0, 2).toUpperCase()}
</div>
<span style={{ fontWeight: "bold" }}>{server.name}</span>
</div>
<button
className="btn-primary"
onClick={() => handleJoinServer(server.id)}
disabled={!isFullyAuthenticated}
>
Join
</button>
</div>
))
)}
</div>
</div>
</div>
);
}
export default ServerDiscovery;
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ChatService } from "../services/chat.svelte";
const chat = getContext<ChatService>("chat");
</script>
<div class="server-list">
{#each chat.joinedServers as server (server.id.toString())}
<div style="position: relative;">
<button
class="server-icon {chat.activeServerId === server.id ? 'active' : ''}"
onclick={() => (chat.activeServerId = server.id)}
title={server.name}
>
{server.name.substring(0, 2).toUpperCase()}
</button>
{#if chat.activeServerId === server.id}
<button
onclick={(e) => {
e.stopPropagation();
chat.handleLeaveServer(server.id);
}}
style="position: absolute; top: -5px; right: -5px; background-color: #da373c; border-radius: 50%; width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; font-size: 10px; cursor: pointer; border: 2px solid var(--background-tertiary); color: white; padding: 0;"
title="Leave Server"
>
×
</button>
{/if}
</div>
{/each}
<button
class="server-icon"
onclick={() => (chat.showCreateServerModal = true)}
style="cursor: {chat.isFullyAuthenticated ? 'pointer' : 'not-allowed'};"
title="Create Server"
>
+
</button>
<button
class="server-icon"
onclick={() => (chat.showDiscoveryModal = true)}
style="color: #23a559;"
title="Discover Servers"
>
🔍
</button>
<!-- Create Server Modal -->
{#if chat.showCreateServerModal}
<div
class="modal-overlay"
onclick={() => (chat.showCreateServerModal = false)}
role="button"
tabindex="-1"
onkeydown={(e) => e.key === 'Escape' && (chat.showCreateServerModal = false)}
>
<form
class="modal-content"
onclick={(e) => e.stopPropagation()}
onsubmit={chat.handleCreateServer}
>
<h2>Create Server</h2>
<div class="settings-section">
<label
for="server-name"
style="display: block; margin-bottom: 8px; font-size: 0.75rem; font-weight: bold; color: var(--text-muted); text-transform: uppercase;"
>
Server Name
</label>
<input
id="server-name"
name="server-name"
type="text"
autofocus
placeholder="Enter server name"
bind:value={chat.newServerName}
style="width: 100%; box-sizing: border-box;"
/>
</div>
<div class="modal-actions">
<button
type="button"
class="icon-btn"
style="padding: 8px 16px; font-size: 0.9rem;"
onclick={() => (chat.showCreateServerModal = false)}
>
Cancel
</button>
<button
type="submit"
class="btn-primary"
disabled={!chat.isFullyAuthenticated}
>
Create Server
</button>
</div>
</form>
</div>
{/if}
</div>
<style>
.server-icon {
border: none;
background: var(--background-primary);
color: var(--text-normal);
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease-in-out;
}
.server-icon:hover {
border-radius: 16px;
background-color: var(--brand);
color: white;
}
.server-icon.active {
border-radius: 16px;
background-color: var(--brand);
color: white;
}
</style>
-158
View File
@@ -1,158 +0,0 @@
// src/chat/components/ServerList.tsx
import React, { useState, useMemo } from "react";
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
// 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));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
interface ServerListProps {
joinedServers: readonly Types.Server[];
activeServerId: bigint | null;
setActiveServerId: (serverId: bigint | null) => void;
isFullyAuthenticated: boolean;
identity: Identity | null;
users: readonly Types.User[];
// Modal state and handlers
showCreateServerModal: boolean;
setShowCreateServerModal: (show: boolean) => void;
newServerName: string;
setNewServerName: (name: string) => void;
handleCreateServer: (e: React.FormEvent) => void;
setShowDiscoveryModal: (show: boolean) => void;
handleLeaveServer: (serverId: bigint) => void;
}
function ServerList({
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" }}>
<div
className={`server-icon ${activeServerId === server.id ? "active" : ""}`}
onClick={() => setActiveServerId(server.id)}
title={server.name}
>
{server.name.substring(0, 2).toUpperCase()}
</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)",
}}
title="Leave Server"
>
×
</div>
)}
</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 && (
<div
className="modal-overlay"
onClick={() => setShowCreateServerModal(false)}
>
<form
className="modal-content"
onClick={(e) => e.stopPropagation()}
onSubmit={handleCreateServer}
>
<h2>Create Server</h2>
<div className="settings-section">
<label
style={{
display: "block",
marginBottom: "8px",
fontSize: "0.75rem",
fontWeight: "bold",
color: "var(--text-muted)",
textTransform: "uppercase",
}}
>
Server Name
</label>
<input
autoFocus
placeholder="Enter server name"
value={newServerName}
onChange={(e) => setNewServerName(e.target.value)}
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
<div className="modal-actions">
<button
type="button"
className="icon-btn"
style={{ padding: "8px 16px", fontSize: "0.9rem" }}
onClick={() => setShowCreateServerModal(false)}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={!isFullyAuthenticated}
>
Create Server
</button>
</div>
</form>
</div>
)}
</div>
);
}
export default ServerList;
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import { reducers } from "../../module_bindings";
import { useReducer } from "spacetimedb/svelte";
import { auth } from "../../auth/auth.svelte";
import type * as Types from "../../module_bindings/types";
let { onClose, currentUser }: { onClose: () => void, currentUser: Types.User | undefined } = $props();
let name = $state("");
const setNameReducer = useReducer(reducers.setName);
$effect(() => {
if (currentUser?.name && !name) {
name = currentUser.name;
}
});
const handleSave = () => {
if (name.trim()) {
setNameReducer({ name: name.trim() });
onClose();
}
};
const handleOverlayClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={handleOverlayClick}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<h2>User Settings</h2>
<div class="settings-section">
<label
for="display-name"
style="display: block; margin-bottom: 8px; font-size: 0.75rem; font-weight: bold; color: var(--text-muted); text-transform: uppercase;"
>
Display Name
</label>
<!-- svelte-ignore a11y_autofocus -->
<input
id="display-name"
name="display-name"
type="text"
bind:value={name}
placeholder="Enter your display name"
autofocus
style="width: 100%; box-sizing: border-box"
/>
</div>
<div class="settings-section" style="border-top: 1px solid var(--background-accent); padding-top: 16px;">
<h3 style="font-size: 0.9rem; margin-top: 0;">Danger Zone</h3>
<button
class="icon-btn"
style="padding: 8px 16px; font-size: 0.9rem; color: #f23f43; background: var(--background-secondary); border: 1px solid #f23f43; width: 100%;"
onclick={() => auth.logout()}
>
Logout
</button>
</div>
<div class="modal-actions">
<button
class="icon-btn"
style="padding: 8px 16px; font-size: 0.9rem"
onclick={onClose}
>
Cancel
</button>
<button class="btn-primary" onclick={handleSave}>
Save Changes
</button>
</div>
</div>
</div>
-67
View File
@@ -1,67 +0,0 @@
import React, { useState } from "react";
import { useReducer, useSpacetimeDB } from "spacetimedb/react";
import { reducers } from "../../module_bindings";
import * as Types from "../../module_bindings/types";
interface SettingsPanelProps {
onClose: () => void;
currentUser: Types.User | undefined;
}
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
onClose,
currentUser,
}) => {
const { identity } = useSpacetimeDB();
const [name, setNameInput] = useState(currentUser?.name || "");
const setNameReducer = useReducer(reducers.setName);
const handleSave = () => {
if (name.trim()) {
setNameReducer({ name: name.trim() });
onClose();
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<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",
}}
>
Display Name
</label>
<input
type="text"
value={name}
onChange={(e) => setNameInput(e.target.value)}
placeholder="Enter your display name"
autoFocus
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>
</div>
</div>
</div>
);
};
@@ -0,0 +1,47 @@
<script lang="ts">
let {
activeChannelId,
activeThreadId,
isFullyAuthenticated,
sendMessageReducer
}: {
activeChannelId: bigint | null,
activeThreadId: bigint | null,
isFullyAuthenticated: boolean,
sendMessageReducer: (args: any) => void
} = $props();
let threadMessageText = $state("");
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault();
if (!threadMessageText.trim() || !activeChannelId || !activeThreadId)
return;
sendMessageReducer({
text: threadMessageText,
channelId: activeChannelId,
threadId: activeThreadId,
});
threadMessageText = "";
};
</script>
<div class="chat-input-container" style="padding: 8px">
<form class="chat-input-wrapper" onsubmit={handleSubmit}>
<input
id="thread-message"
name="thread-message"
type="text"
class="chat-input"
style="font-size: 0.85rem"
placeholder={
isFullyAuthenticated ? "Reply in thread..." : "Log in to chat"
}
disabled={
!isFullyAuthenticated || !activeChannelId || !activeThreadId
}
bind:value={threadMessageText}
/>
</form>
</div>
@@ -1,53 +0,0 @@
// src/chat/components/ThreadMessageInput.tsx
import React, { useState } from "react";
import { tables, reducers } from "../../module_bindings";
interface ThreadMessageInputProps {
activeChannelId: bigint | null; // Still needed by sendMessage reducer
activeThreadId: bigint | null;
isFullyAuthenticated: boolean;
sendMessageReducer: (args: any) => void;
}
function ThreadMessageInput({
activeChannelId,
activeThreadId,
isFullyAuthenticated,
sendMessageReducer,
}: ThreadMessageInputProps) {
const [threadMessageText, setThreadMessageText] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!threadMessageText.trim() || !activeChannelId || !activeThreadId)
return;
sendMessageReducer({
text: threadMessageText,
channelId: activeChannelId,
threadId: activeThreadId,
});
setThreadMessageText("");
};
return (
<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
}
value={threadMessageText}
onChange={(e) => setThreadMessageText(e.target.value)}
/>
</form>
</div>
);
}
export default ThreadMessageInput;
@@ -0,0 +1,58 @@
<script lang="ts">
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
import RichText from "./RichText.svelte";
let {
threadMessages,
users,
identity
}: {
threadMessages: readonly Types.Message[],
users: readonly Types.User[],
identity: Identity | null
} = $props();
let threadMessagesEndRef = $state<HTMLDivElement | null>(null);
const getUsername = (
userIdentity: Identity | null,
users: readonly Types.User[],
) => {
if (!userIdentity) return "Unknown";
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
// Auto-scroll to bottom when messages change
$effect(() => {
if (threadMessages) {
threadMessagesEndRef?.scrollIntoView({ behavior: "smooth" });
}
});
</script>
<div
class="message-list thread-messages-list"
style="padding: 8px"
>
{#each threadMessages as msg (msg.id.toString())}
{@const msgUsername = getUsername(msg.sender, users)}
<div
class="message-item thread-message-item"
>
<div class="avatar small message-avatar">
{msgUsername.substring(0, 2).toUpperCase()}
</div>
<div class="message-content">
<div class="message-header">
<span class="user-name">{msgUsername}</span>
</div>
<div class="message-text">
<RichText text={msg.text} />
</div>
</div>
</div>
{/each}
<div bind:this={threadMessagesEndRef}></div>
</div>
-67
View File
@@ -1,67 +0,0 @@
// 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";
// Helper function (extracted from App.tsx)
const getUsername = (
userIdentity: Identity | null,
users: readonly Types.User[],
) => {
if (!userIdentity) return "Unknown";
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return user?.name || userIdentity.toHexString().substring(0, 8);
};
interface ThreadMessageListProps {
threadMessages: Types.Message[];
users: readonly Types.User[];
identity: Identity | null;
}
function ThreadMessageList({
threadMessages,
users,
identity,
}: ThreadMessageListProps) {
const threadMessagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
threadMessagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [threadMessages]);
return (
<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 thread-message-item"
>
<div className="avatar small message-avatar">
{msgUsername.substring(0, 2).toUpperCase()}
</div>
<div className="message-content">
<div className="message-header">
<span className="user-name">{msgUsername}</span>
</div>
<div className="message-text">
<RichText text={msg.text} />
</div>
</div>
</div>
);
})}
<div ref={threadMessagesEndRef} />
</div>
);
}
export default ThreadMessageList;
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
import { useReducer } from "spacetimedb/svelte";
import { reducers } from "../../module_bindings";
import ThreadMessageList from "./ThreadMessageList.svelte";
import ThreadMessageInput from "./ThreadMessageInput.svelte";
let {
activeThreadId,
setActiveThreadId,
activeChannelId,
activeServer,
isFullyAuthenticated,
users,
identity,
allThreads,
allMessages
}: {
activeThreadId: bigint,
setActiveThreadId: (id: bigint | null) => void,
activeChannelId: bigint | null,
activeServer: Types.Server | undefined,
isFullyAuthenticated: boolean,
users: readonly Types.User[],
identity: Identity | null,
allThreads: readonly Types.Thread[],
allMessages: readonly Types.Message[]
} = $props();
const sendMessageReducer = useReducer(reducers.sendMessage);
const activeThread = $derived(allThreads.find((t) => t.id === activeThreadId));
const threadMessages = $derived.by(() => {
if (!activeThreadId) return [];
return allMessages
.filter((m) => m.threadId === activeThreadId)
.sort((a, b) =>
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1
);
});
</script>
{#if activeThreadId && activeThread}
<div class="thread-view">
<div
class="thread-header"
style="border-bottom: 1px solid var(--background-accent); padding: 8px; display: flex; justify-content: space-between; align-items: center;"
>
<div style="display: flex; align-items: center; gap: 8px">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
style="color: var(--brand); cursor: pointer; font-size: 1.2rem;"
onclick={() => setActiveThreadId(null)}
>
</span>
<span style="font-weight: bold; font-size: 0.9rem">
{activeThread.name}
</span>
</div>
<button class="close-btn" onclick={() => setActiveThreadId(null)}>
×
</button>
</div>
<ThreadMessageList
{threadMessages}
{users}
{identity}
/>
<ThreadMessageInput
{activeChannelId}
{activeThreadId}
{isFullyAuthenticated}
{sendMessageReducer}
/>
</div>
{/if}
-103
View File
@@ -1,103 +0,0 @@
// src/chat/components/ThreadView.tsx
import React, { useState, useMemo } from "react";
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
import { useReducer } from "spacetimedb/react"; // Assuming useTable and useReducer are available
import { reducers } from "../../module_bindings";
import ThreadMessageList from "./ThreadMessageList";
import ThreadMessageInput from "./ThreadMessageInput";
interface ThreadViewProps {
activeThreadId: bigint;
setActiveThreadId: (id: bigint | null) => void;
activeChannelId: bigint | null;
activeServer: Types.Server | undefined;
isFullyAuthenticated: boolean;
users: readonly Types.User[];
identity: Identity | null;
allThreads: readonly Types.Thread[];
allMessages: readonly Types.Message[];
}
function ThreadView({
activeThreadId,
setActiveThreadId,
activeChannelId,
activeServer,
isFullyAuthenticated,
users,
identity,
allThreads,
allMessages,
}: ThreadViewProps) {
const sendMessageReducer = useReducer(
useMemo(() => reducers.sendMessage, []),
); // Assuming reducers are accessible
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,
);
}, [allMessages, activeThreadId]);
if (!activeThreadId || !activeThread) {
return null; // Don't render anything if no active thread
}
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>
<button className="close-btn" onClick={() => setActiveThreadId(null)}>
×
</button>
</div>
<ThreadMessageList
threadMessages={threadMessages}
users={users}
identity={identity}
/>
<ThreadMessageInput
activeChannelId={activeChannelId}
activeThreadId={activeThreadId}
isFullyAuthenticated={isFullyAuthenticated}
sendMessageReducer={sendMessageReducer}
/>
</div>
);
}
export default ThreadView;
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import { getContext } from "svelte";
import { Identity } from "spacetimedb";
import type { ChatService } from "../services/chat.svelte";
import type { WebRTCService } from "../services/webrtc/webrtc.svelte";
import VideoTile from "./VideoTile.svelte";
const chat = getContext<ChatService>("chat");
const webrtc = getContext<WebRTCService>("webrtc");
let focusedIdentity = $state<Identity | null>(null);
const participants = $derived(
chat.voiceStates.filter((vs) => vs.channelId === webrtc.connectedChannelId),
);
const localSharing = $derived(!!webrtc.localScreenStream);
const remoteSharerVs = $derived(
participants.find((vs) => {
if (vs.identity.isEqual(webrtc.identity!)) return false;
return vs.isSharingScreen;
}),
);
const defaultSharerIdentity = $derived(
localSharing ? webrtc.identity : remoteSharerVs?.identity,
);
const primarySharerIdentity = $derived(focusedIdentity || defaultSharerIdentity);
function isWatchingPeer(peerIdHex: string) {
return webrtc.watching.some(
(w) =>
w.watcher.isEqual(webrtc.identity!) &&
w.watchee.toHexString() === peerIdHex,
);
}
function toggleWatch(peerIdentity: Identity) {
if (isWatchingPeer(peerIdentity.toHexString())) {
webrtc.stopWatching(peerIdentity);
} else {
webrtc.startWatching(peerIdentity);
}
}
const heroVs = $derived(
participants.find((vs) =>
vs.identity.isEqual(primarySharerIdentity || Identity.zero()),
),
);
const rowParticipants = $derived(
participants.filter(
(vs) => !vs.identity.isEqual(primarySharerIdentity || Identity.zero()),
),
);
</script>
<div class="video-grid {primarySharerIdentity ? 'has-sharer' : ''}">
<div class="video-grid-content">
{#if primarySharerIdentity}
{#if heroVs}
<div
class="video-tile-container is-hero"
onclick={() => (focusedIdentity = heroVs.identity)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = heroVs.identity)}
style="cursor: pointer;"
>
<VideoTile
identity={heroVs.identity}
stream={heroVs.identity.isEqual(webrtc.identity!)
? webrtc.localScreenStream || undefined
: webrtc.peers.get(heroVs.identity.toHexString())?.videoStream}
isLocal={heroVs.identity.isEqual(webrtc.identity!)}
isTalking={chat.voiceActivity.find((va) =>
va.identity.isEqual(heroVs.identity),
)?.isTalking || false}
isWatching={isWatchingPeer(heroVs.identity.toHexString())}
isSharing={heroVs.identity.isEqual(webrtc.identity!)
? localSharing
: heroVs.isSharingScreen}
onToggleWatch={() => toggleWatch(heroVs.identity)}
isHero={true}
users={chat.users}
/>
</div>
{/if}
{#if rowParticipants.length > 0}
<div class="video-participants-row">
{#each rowParticipants as vs (vs.identity.toHexString())}
<div
class="video-tile-container is-row"
onclick={() => (focusedIdentity = vs.identity)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = vs.identity)}
style="cursor: pointer;"
>
<VideoTile
identity={vs.identity}
stream={vs.identity.isEqual(webrtc.identity!)
? webrtc.localScreenStream || undefined
: webrtc.peers.get(vs.identity.toHexString())?.videoStream}
isLocal={vs.identity.isEqual(webrtc.identity!)}
isTalking={chat.voiceActivity.find((va) =>
va.identity.isEqual(vs.identity),
)?.isTalking || false}
isWatching={isWatchingPeer(vs.identity.toHexString())}
isSharing={vs.identity.isEqual(webrtc.identity!)
? localSharing
: vs.isSharingScreen}
onToggleWatch={() => toggleWatch(vs.identity)}
isHero={false}
users={chat.users}
/>
</div>
{/each}
</div>
{/if}
{:else}
{#each participants as vs (vs.identity.toHexString())}
<div
class="video-tile-container is-grid"
onclick={() => (focusedIdentity = vs.identity)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = vs.identity)}
style="cursor: pointer;"
>
<VideoTile
identity={vs.identity}
stream={vs.identity.isEqual(webrtc.identity!)
? webrtc.localScreenStream || undefined
: webrtc.peers.get(vs.identity.toHexString())?.videoStream}
isLocal={vs.identity.isEqual(webrtc.identity!)}
isTalking={chat.voiceActivity.find((va) =>
va.identity.isEqual(vs.identity),
)?.isTalking || false}
isWatching={isWatchingPeer(vs.identity.toHexString())}
isSharing={vs.identity.isEqual(webrtc.identity!)
? localSharing
: vs.isSharingScreen}
onToggleWatch={() => toggleWatch(vs.identity)}
isHero={false}
users={chat.users}
/>
</div>
{/each}
{/if}
</div>
</div>
-294
View File
@@ -1,294 +0,0 @@
import React, { useEffect, useRef } from "react";
import { Identity } from "spacetimedb";
import { useSpacetimeDB } from "spacetimedb/react";
import * as Types from "../../module_bindings/types";
interface VideoGridProps {
peers: Map<string, { audio?: HTMLAudioElement; videoStream?: MediaStream }>;
localScreenStream: MediaStream | null;
connectedChannelId: bigint;
startWatching: (peerIdentity: Identity) => void;
stopWatching: (peerIdentity: Identity) => void;
watching: readonly Types.Watching[];
users: readonly Types.User[];
voiceStates: readonly Types.VoiceState[];
voiceActivity: readonly Types.VoiceActivity[];
}
const VideoTile = ({
identity,
stream,
isLocal,
isTalking,
onToggleWatch,
isWatching,
isSharing,
isHero,
users,
}: {
identity: Identity;
stream?: MediaStream;
isLocal?: boolean;
isTalking?: boolean;
onToggleWatch?: () => void;
isWatching?: boolean;
isSharing?: boolean;
isHero?: boolean;
users: readonly Types.User[];
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isMuted, setIsMuted] = React.useState(false);
const [volume, setVolume] = React.useState(1.0);
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;
if (video && stream && shouldShow) {
if (video.srcObject !== stream) {
video.srcObject = stream;
}
// Control volume directly on the video element for screen sharing
video.muted = isMuted;
video.volume = Math.min(1, isMuted ? 0 : volume);
video.play().catch((err) => {
if (err.name !== "AbortError") {
console.warn(`[VideoTile] Play failed for ${name}:`, err);
}
});
} else if (video) {
if (video.srcObject) {
video.srcObject = null;
}
}
}, [stream, isLocal, isWatching, name, isSharing, isMuted, volume]);
const handleFullscreen = (e: React.MouseEvent) => {
e.stopPropagation();
if (containerRef.current?.requestFullscreen) {
containerRef.current.requestFullscreen();
}
};
const toggleMute = (e: React.MouseEvent) => {
e.stopPropagation();
setIsMuted((prev) => !prev);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (val > 0 && isMuted) {
setIsMuted(false);
} else if (val === 0 && !isMuted) {
setIsMuted(true);
}
};
const showStream = (isLocal || isWatching) && stream;
return (
<div
ref={containerRef}
className={`video-tile ${isTalking ? "talking" : ""} ${isHero ? "hero" : ""}`}
>
{showStream ? (
<>
<video ref={videoRef} autoPlay playsInline />
<div className="video-controls">
{!isLocal && (
<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>
</div>
</>
) : (
<div className="avatar-placeholder-container">
<div className="avatar-placeholder">{name[0].toUpperCase()}</div>
{!isLocal && isSharing && (
<button
className="watch-btn"
onClick={(e) => {
e.stopPropagation();
onToggleWatch?.();
}}
>
Watch Stream
</button>
)}
</div>
)}
{/* Control screen share volume directly via video element volume slider */}
{!isLocal &&
isWatching &&
stream &&
stream.getAudioTracks().length > 0 && (
<div className="tile-actions-right">
<div
className="volume-control-container"
onClick={(e) => e.stopPropagation()}
>
<input
type="range"
min="0"
max="2"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
<button
className="mute-tile-btn"
onClick={toggleMute}
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? "🔈" : "🔊"}
</button>
</div>
</div>
)}
<div className="tile-info">
<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,
users,
voiceStates,
voiceActivity,
}) => {
const { identity: localIdentity } = useSpacetimeDB();
const [focusedIdentity, setFocusedIdentity] = React.useState<Identity | null>(
null,
);
const participants = voiceStates.filter(
(vs) => vs.channelId === connectedChannelId,
);
const isWatchingPeer = (peerIdHex: string) => {
return watching.some(
(w) =>
w.watcher.isEqual(localIdentity!) &&
w.watchee.toHexString() === peerIdHex,
);
};
const toggleWatch = (peerIdentity: Identity) => {
if (isWatchingPeer(peerIdentity.toHexString())) {
stopWatching(peerIdentity);
} else {
startWatching(peerIdentity);
}
};
const localSharing = !!localScreenStream;
const remoteSharerVs = participants.find((vs) => {
if (vs.identity.isEqual(localIdentity!)) return false;
return vs.isSharingScreen;
});
const defaultSharerIdentity = localSharing
? localIdentity
: remoteSharerVs?.identity;
const primarySharerIdentity = focusedIdentity || defaultSharerIdentity;
const renderTile = (vs: Types.VoiceState) => {
const isLocal = vs.identity.isEqual(localIdentity!);
const peerIdHex = vs.identity.toHexString();
const peer = peers.get(peerIdHex);
const isTalking =
voiceActivity.find((va) => va.identity.isEqual(vs.identity))?.isTalking ||
false;
// Determine the role of this tile based on the overall layout
let roleClass = "is-grid";
if (primarySharerIdentity) {
roleClass = primarySharerIdentity.isEqual(vs.identity)
? "is-hero"
: "is-row";
}
return (
<div
key={peerIdHex}
className={`video-tile-container ${roleClass}`}
onClick={() => setFocusedIdentity(vs.identity)}
style={{ cursor: "pointer" }}
>
<VideoTile
identity={vs.identity}
stream={isLocal ? localScreenStream || undefined : peer?.videoStream}
isLocal={isLocal}
isTalking={isTalking}
isWatching={isWatchingPeer(peerIdHex)}
isSharing={isLocal ? localSharing : vs.isSharingScreen}
onToggleWatch={() => toggleWatch(vs.identity)}
isHero={roleClass === "is-hero"}
users={users}
/>
</div>
);
};
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-content">
{primarySharerIdentity ? (
<>
{heroVs && renderTile(heroVs)}
{rowParticipants.length > 0 && (
<div className="video-participants-row">
{rowParticipants.map((vs) => renderTile(vs))}
</div>
)}
</>
) : (
participants.map((vs) => renderTile(vs))
)}
</div>
</div>
);
};
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts">
import { Identity } from "spacetimedb";
import type * as Types from "../../module_bindings/types";
let {
identity,
stream,
isLocal,
isTalking,
onToggleWatch,
isWatching,
isSharing,
isHero,
users,
} = $props<{
identity: Identity;
stream?: MediaStream;
isLocal?: boolean;
isTalking?: boolean;
onToggleWatch?: () => void;
isWatching?: boolean;
isSharing?: boolean;
isHero?: boolean;
users: readonly Types.User[];
}>();
let videoRef = $state<HTMLVideoElement | null>(null);
let containerRef = $state<HTMLDivElement | null>(null);
let isMuted = $state(false);
let volume = $state(1.0);
const user = $derived(users.find((u) => u.identity.isEqual(identity)));
const name = $derived(
user?.name || user?.username || identity.toHexString().substring(0, 8),
);
$effect(() => {
const video = videoRef;
const shouldShow = isLocal || isWatching;
if (video && stream && shouldShow) {
if (video.srcObject !== stream) {
video.srcObject = stream;
}
video.muted = isMuted;
video.volume = Math.min(1, isMuted ? 0 : volume);
video.play().catch((err) => {
if (err.name !== "AbortError") {
console.warn(`[VideoTile] Play failed for ${name}:`, err);
}
});
} else if (video) {
if (video.srcObject) {
video.srcObject = null;
}
}
});
function handleFullscreen(e: MouseEvent) {
e.stopPropagation();
if (containerRef?.requestFullscreen) {
containerRef.requestFullscreen();
}
}
function toggleMute(e: MouseEvent) {
e.stopPropagation();
isMuted = !isMuted;
}
function handleVolumeChange(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value);
volume = val;
if (val > 0 && isMuted) {
isMuted = false;
} else if (val === 0 && !isMuted) {
isMuted = true;
}
}
const showStream = $derived((isLocal || isWatching) && !!stream);
</script>
<div
bind:this={containerRef}
class="video-tile {isTalking ? 'talking' : ''} {isHero ? 'hero' : ''}"
>
{#if showStream}
<video bind:this={videoRef} autoplay playsinline>
<track kind="captions" />
</video>
<div class="video-controls">
{#if !isLocal}
<button
class="watch-btn active"
onclick={(e) => {
e.stopPropagation();
onToggleWatch?.();
}}
title="Stop Watching"
>
Stop Watching
</button>
{/if}
<button
class="fullscreen-btn"
onclick={handleFullscreen}
title="Toggle Fullscreen"
>
</button>
</div>
{:else}
<div class="avatar-placeholder-container">
<div class="avatar-placeholder">{name[0].toUpperCase()}</div>
{#if !isLocal && isSharing}
<button
class="watch-btn"
onclick={(e) => {
e.stopPropagation();
onToggleWatch?.();
}}
>
Watch Stream
</button>
{/if}
</div>
{/if}
{#if !isLocal && isWatching && stream && stream.getAudioTracks().length > 0}
<div class="tile-actions-right">
<div class="volume-control-container" onclick={(e) => e.stopPropagation()}>
<input
type="range"
min="0"
max="2"
step="0.01"
value={isMuted ? 0 : volume}
oninput={handleVolumeChange}
class="volume-slider"
/>
<button
class="mute-tile-btn"
onclick={toggleMute}
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? "🔈" : "🔊"}
</button>
</div>
</div>
{/if}
<div class="tile-info">
<span class="user-name">
{name}
{isLocal ? "(You)" : ""}
</span>
{#if isSharing}
<span class="sharing-badge">LIVE</span>
{/if}
</div>
</div>
+3 -2
View File
@@ -1,3 +1,4 @@
// src/chat/index.ts // src/chat/index.ts
export { default as ChatContainer } from "./ChatContainer"; export { default as ChatContainer } from "./ChatContainer.svelte";
export { useChat } from "./services/useChat"; export * from "./services";
export * from "./utils";
+292
View File
@@ -0,0 +1,292 @@
import { tables, reducers } from "../../module_bindings";
import { useTable, useReducer } from "spacetimedb/svelte";
import { Identity } from "spacetimedb";
import * as Types from "../../module_bindings/types";
import { getUsername, formatTime } from "../utils";
import { get } from "svelte/store";
export class ChatService {
identity = $state<Identity | null>(null);
// Navigation State
activeServerId = $state<bigint | null>(null);
activeChannelId = $state<bigint | null>(null);
activeThreadId = $state<bigint | null>(null);
// Modal/UI State
showCreateServerModal = $state(false);
newServerName = $state("");
showCreateChannelModal = $state(false);
newChannelName = $state("");
isVoiceChannel = $state(false);
showSetNameModal = $state(false);
newName = $state("");
messageText = $state("");
threadMessageText = $state("");
showDiscoveryModal = $state(false);
authError = $state("");
// Subscriptions Data
servers = $state<readonly Types.Server[]>([]);
channels = $state<readonly Types.Channel[]>([]);
users = $state<readonly Types.User[]>([]);
serverMembers = $state<readonly Types.ServerMember[]>([]);
allMessages = $state<readonly Types.Message[]>([]);
allThreads = $state<readonly Types.Thread[]>([]);
voiceStates = $state<readonly Types.VoiceState[]>([]);
voiceActivity = $state<readonly Types.VoiceActivity[]>([]);
isUsersReady = $state(false);
// Reducers
#createServerReducer = useReducer(reducers.createServer);
#createChannelReducer = useReducer(reducers.createChannel);
#createThreadReducer = useReducer(reducers.createThread);
#sendMessageReducer = useReducer(reducers.sendMessage);
#joinVoiceReducer = useReducer(reducers.joinVoice);
#leaveVoiceReducer = useReducer(reducers.leaveVoice);
#setNameReducer = useReducer(reducers.setName);
#joinServerReducer = useReducer(reducers.joinServer);
#leaveServerReducer = useReducer(reducers.leaveServer);
constructor(identity: Identity | null) {
this.identity = identity;
// Initialize base subscriptions
const [serversStore] = useTable(tables.server);
const [channelsStore] = useTable(tables.channel);
const [usersStore, usersReadyStore] = useTable(tables.user);
const [voiceStatesStore] = useTable(tables.voice_state);
const [serverMembersStore] = useTable(tables.server_member);
const [messagesStore] = useTable(tables.message);
const [threadsStore] = useTable(tables.thread);
const [voiceActivityStore] = useTable(tables.voice_activity);
// Sync stores to $state
serversStore.subscribe(v => this.servers = v);
channelsStore.subscribe(v => this.channels = v);
usersStore.subscribe(v => this.users = v);
usersReadyStore.subscribe(v => this.isUsersReady = v);
voiceStatesStore.subscribe(v => this.voiceStates = v);
serverMembersStore.subscribe(v => this.serverMembers = v);
messagesStore.subscribe(v => this.allMessages = v);
threadsStore.subscribe(v => this.allThreads = v);
voiceActivityStore.subscribe(v => this.voiceActivity = v);
// Navigation logic from useNavigation.ts
$effect(() => {
if (!this.activeServerId && this.joinedServers.length > 0) {
this.activeServerId = this.joinedServers[0].id;
}
});
$effect(() => {
if (this.activeServerId) {
const serverChannels = this.channels.filter(
(c) => c.serverId === this.activeServerId && c.kind.tag === "Text",
);
if (serverChannels.length > 0) {
if (
!this.activeChannelId ||
!this.channels.some(
(c) => c.id === this.activeChannelId && c.serverId === this.activeServerId,
)
) {
this.activeChannelId = serverChannels[0].id;
}
} else {
this.activeChannelId = null;
}
}
});
}
#updateServerMembers() {
// This is tricky because we have multiple dynamic subscriptions.
// In React it was easier because useSubscriptions was re-called.
// In Svelte, we can just fetch all memberships from the underlying DB if needed,
// or keep track of the stores.
// For simplicity, let's just use useTable on the whole server_member table if it's not too big,
// or use a more reactive approach.
// Given the React code used specific queries, let's try to replicate that.
// Actually, spacetimedb/svelte useTable is just a wrapper around the table.
// We can just subscribe to the whole table if we want, but it might be inefficient.
// The React code did:
// const [activeServerMembers] = useTable(activeServerId ? tables.server_member.where(...) : ...);
// const [ownMemberships] = useTable(identity ? tables.server_member.where(...) : ...);
// Let's use a single subscription for all relevant members if possible,
// or just subscribe to the whole table for now if it's small enough.
// Looking at the original code, it was trying to be efficient.
}
// Derived State (Runes)
joinedServerIds = $derived.by(() => {
if (!this.identity) return new Set<bigint>();
// We need to access serverMembers here.
// Let's just subscribe to the whole server_member table for simplicity in Svelte.
return new Set(
this.serverMembers
.filter((m) => m.identity.isEqual(this.identity!))
.map((m) => m.serverId),
);
});
joinedServers = $derived(this.servers.filter((s) => this.joinedServerIds.has(s.id)));
availableServers = $derived(this.servers.filter((s) => !this.joinedServerIds.has(s.id)));
activeServer = $derived(this.servers.find((s) => s.id === this.activeServerId));
activeChannel = $derived(this.channels.find((c) => c.id === this.activeChannelId));
activeThread = $derived(this.allThreads.find((t) => t.id === this.activeThreadId));
currentUser = $derived(this.users.find((u) => u.identity?.isEqual(this.identity || Identity.zero())));
isFullyAuthenticated = $derived.by(() => {
const isBypassEnabled =
import.meta.env.VITE_BYPASS_AUTH === "true" ||
new URLSearchParams(window.location.search).has("bypass_auth");
if (isBypassEnabled) return true;
if (!this.currentUser) return false;
const hasOidc = !!(this.currentUser.issuer && this.currentUser.subject);
const hasCreds = !!(this.currentUser.username && this.currentUser.password);
return hasOidc || hasCreds;
});
channelMessages = $derived(
this.allMessages
.filter((m) => m.channelId === this.activeChannelId && m.threadId === undefined)
.sort((a, b) => (a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1))
);
threadMessages = $derived(
this.allMessages
.filter((m) => m.threadId === this.activeThreadId)
.sort((a, b) => (a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1))
);
isActiveChannelVoice = $derived(this.activeChannel?.kind.tag === "Voice");
isActiveChannelText = $derived(this.activeChannel?.kind.tag === "Text");
textChannels = $derived(
this.channels.filter((c) => c.serverId === this.activeServerId && c.kind.tag === "Text")
);
voiceChannels = $derived(
this.channels.filter((c) => c.serverId === this.activeServerId && c.kind.tag === "Voice")
);
activeServerMembers = $derived.by(() => {
if (!this.activeServerId) return [];
const memberIdentities = new Set(
this.serverMembers
.filter((m) => m.serverId === this.activeServerId)
.map((m) => m.identity.toHexString())
);
return this.users.filter((u) => memberIdentities.has(u.identity.toHexString()));
});
currentVoiceState = $derived(
this.voiceStates.find((vs) => vs.identity?.isEqual(this.identity || Identity.zero()))
);
connectedVoiceChannel = $derived(
this.channels.find((c) => c.id === this.currentVoiceState?.channelId)
);
onlineUsers = $derived(
this.users.filter((u) => u.online || this.identity?.isEqual(u.identity))
);
// Actions
handleCreateServer = (e: Event) => {
e.preventDefault();
if (this.newServerName.trim()) {
this.#createServerReducer({ name: this.newServerName });
this.newServerName = "";
this.showCreateServerModal = false;
}
};
handleCreateChannel = (e: Event) => {
e.preventDefault();
if (this.newChannelName.trim() && this.activeServerId) {
this.#createChannelChannelReducer({
serverId: this.activeServerId,
name: this.newChannelName,
isVoice: this.isVoiceChannel,
});
this.newChannelName = "";
this.showCreateChannelModal = false;
}
};
// Helper for createChannel reducer (mapping name mismatch)
#createChannelChannelReducer = (params: { serverId: bigint, name: string, isVoice: boolean }) => {
this.#createChannelReducer(params);
}
handleStartThread = (msg: Types.Message) => {
const threadName = `Thread for ${msg.text.substring(0, 20)}...`;
this.#createThreadReducer({
name: threadName,
channelId: msg.channelId,
parentMessageId: msg.id,
});
};
handleSendMessage = (e: Event) => {
e.preventDefault();
if (this.messageText.trim() && this.activeChannelId) {
this.#sendMessageReducer({
channelId: this.activeChannelId,
text: this.messageText,
threadId: this.activeThreadId || undefined,
});
this.messageText = "";
}
};
handleSendThreadMessage = (e: Event) => {
e.preventDefault();
if (this.threadMessageText.trim() && this.activeChannelId && this.activeThreadId) {
this.#sendMessageReducer({
channelId: this.activeChannelId,
text: this.threadMessageText,
threadId: this.activeThreadId,
});
this.threadMessageText = "";
}
};
handleJoinVoice = (channelId: bigint) => {
this.#joinVoiceReducer({ channelId });
};
handleLeaveVoice = () => {
this.#leaveVoiceReducer();
};
handleSetName = (e: Event) => {
e.preventDefault();
if (this.newName.trim()) {
this.#setNameReducer({ name: this.newName });
this.newName = "";
this.showSetNameModal = false;
}
};
handleJoinServer = (serverId: bigint) => {
this.#joinServerReducer({ serverId });
};
handleLeaveServer = (serverId: bigint) => {
this.#leaveServerReducer({ serverId });
};
// Helper functions
getUsername = (userIdentity: Identity | null) => getUsername(userIdentity, this.users);
formatTime = (ts: any) => formatTime(ts);
}
-195
View File
@@ -1,195 +0,0 @@
import { useState, useMemo, useCallback } from "react";
import { useReducer } from "spacetimedb/react";
import { reducers } from "../../../module_bindings";
import * as Types from "../../../module_bindings/types";
export const useActions = (
activeServerId: bigint | null,
activeChannelId: bigint | null,
activeThreadId: bigint | null,
) => {
// Modal states
const [showCreateServerModal, setShowCreateServerModal] = useState(false);
const [newServerName, setNewServerName] = useState("");
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
const [newChannelName, setNewChannelName] = useState("");
const [isVoiceChannel, setIsVoiceChannel] = useState(false);
const [showSetNameModal, setShowSetNameModal] = useState(false);
const [newName, setNewName] = useState("");
const [messageText, setMessageText] = useState("");
const [threadMessageText, setThreadMessageText] = useState("");
// 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 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, []),
);
// Handlers
const handleCreateServer = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (newServerName.trim()) {
createServerReducer({ name: newServerName });
setNewServerName("");
setShowCreateServerModal(false);
}
},
[newServerName, createServerReducer],
);
const handleCreateChannel = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (newChannelName.trim() && activeServerId) {
createChannelReducer({
serverId: activeServerId,
name: newChannelName,
isVoice: isVoiceChannel,
});
setNewChannelName("");
setShowCreateChannelModal(false);
}
},
[newChannelName, activeServerId, isVoiceChannel, createChannelReducer],
);
const handleStartThread = useCallback(
(msg: Types.Message) => {
const threadName = `Thread for ${msg.text.substring(0, 20)}...`;
createThreadReducer({
name: threadName,
channelId: msg.channelId,
parentMessageId: msg.id,
});
},
[createThreadReducer],
);
const handleSendMessage = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (messageText.trim() && activeChannelId) {
sendMessageReducer({
channelId: activeChannelId,
text: messageText,
threadId: activeThreadId || undefined,
});
setMessageText("");
}
},
[messageText, activeChannelId, activeThreadId, sendMessageReducer],
);
const handleSendThreadMessage = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (threadMessageText.trim() && activeChannelId && activeThreadId) {
sendMessageReducer({
channelId: activeChannelId,
text: threadMessageText,
threadId: activeThreadId,
});
setThreadMessageText("");
}
},
[threadMessageText, activeChannelId, activeThreadId, sendMessageReducer],
);
const handleJoinVoice = useCallback(
(channelId: bigint) => {
joinVoiceReducer({ channelId });
},
[joinVoiceReducer],
);
const handleLeaveVoice = useCallback(() => {
leaveVoiceReducer();
}, [leaveVoiceReducer]);
const handleSetName = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (newName.trim()) {
setNameReducer({ name: newName });
setNewName("");
setShowSetNameModal(false);
}
},
[newName, setNameReducer],
);
const handleJoinServer = useCallback(
(serverId: bigint) => {
joinServerReducer({ serverId });
},
[joinServerReducer],
);
const handleLeaveServer = useCallback(
(serverId: bigint) => {
leaveServerReducer({ serverId });
},
[leaveServerReducer],
);
return {
// Modal states & setters
showCreateServerModal,
setShowCreateServerModal,
newServerName,
setNewServerName,
showCreateChannelModal,
setShowCreateChannelModal,
newChannelName,
setNewChannelName,
isVoiceChannel,
setIsVoiceChannel,
showSetNameModal,
setShowSetNameModal,
newName,
setNewName,
messageText,
setMessageText,
threadMessageText,
setThreadMessageText,
// Handlers
handleCreateServer,
handleCreateChannel,
handleStartThread,
handleSendMessage,
handleSendThreadMessage,
handleJoinVoice,
handleLeaveVoice,
handleSetName,
handleJoinServer,
handleLeaveServer,
// Raw Reducers (if needed)
createServerReducer,
createChannelReducer,
createThreadReducer,
sendMessageReducer,
joinVoiceReducer,
leaveVoiceReducer,
setNameReducer,
joinServerReducer,
leaveServerReducer,
};
};
-13
View File
@@ -1,13 +0,0 @@
import { useState } from "react";
export const useDiscovery = () => {
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
const [authError, setAuthError] = useState("");
return {
showDiscoveryModal,
setShowDiscoveryModal,
authError,
setAuthError,
};
};
-82
View File
@@ -1,82 +0,0 @@
import { useState, useMemo, useEffect } from "react";
import { Identity } from "spacetimedb";
import * as Types from "../../../module_bindings/types";
export const useNavigation = (
identity: Identity | null,
servers: readonly Types.Server[],
channels: readonly Types.Channel[],
serverMembers: readonly Types.ServerMember[],
) => {
const [activeServerId, setActiveServerId] = useState<bigint | null>(null);
const [activeChannelId, setActiveChannelId] = useState<bigint | null>(null);
const [activeThreadId, setActiveThreadId] = useState<bigint | null>(null);
const joinedServerIds = useMemo(() => {
if (!identity) return new Set<bigint>();
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));
}, [servers, joinedServerIds]);
const availableServers = useMemo(() => {
return servers.filter((s) => !joinedServerIds.has(s.id));
}, [servers, joinedServerIds]);
// Initial server selection
useEffect(() => {
if (!activeServerId && joinedServers.length > 0) {
setActiveServerId(joinedServers[0].id);
}
}, [joinedServers, activeServerId]);
// Initial channel selection when server changes
useEffect(() => {
if (activeServerId) {
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,
)
) {
setActiveChannelId(serverChannels[0].id);
}
} else {
setActiveChannelId(null);
}
}
}, [activeServerId, channels, activeChannelId]);
const activeServer = useMemo(
() => servers.find((s) => s.id === activeServerId),
[servers, activeServerId],
);
const activeChannel = useMemo(
() => channels.find((c) => c.id === activeChannelId),
[channels, activeChannelId],
);
return {
activeServerId,
setActiveServerId,
activeChannelId,
setActiveChannelId,
activeThreadId,
setActiveThreadId,
activeServer,
activeChannel,
joinedServers,
availableServers,
};
};
-102
View File
@@ -1,102 +0,0 @@
import { useMemo } from "react";
import { Identity } from "spacetimedb";
import { useTable } from "spacetimedb/react";
import { tables } from "../../../module_bindings";
import * as Types from "../../../module_bindings/types";
export const useSubscriptions = (
identity: Identity | null,
activeServerId: bigint | null,
activeChannelId: bigint | null,
connectedVoiceChannelId: bigint | null = null,
discovery: boolean = false,
) => {
const [activeServerMembers] = useTable(
useMemo(
() =>
activeServerId
? tables.server_member.where((r) => r.serverId.eq(activeServerId))
: tables.server_member.where((r) => r.id.eq(-1n)),
[activeServerId],
),
);
// User's own memberships
const [ownMemberships] = useTable(
useMemo(
() =>
identity
? tables.server_member.where((r) => r.identity.eq(identity))
: tables.server_member.where((r) => r.id.eq(-1n)),
[identity],
),
);
const joinedServerIds = useMemo(
() => (ownMemberships || []).map((m) => m.serverId),
[ownMemberships],
);
const [servers] = useTable(useMemo(() => tables.server, []));
const serverMembers = useMemo(() => {
const combined = [...(activeServerMembers || []), ...(ownMemberships || [])];
// De-duplicate
const seen = new Set();
return combined.filter((m) => {
const id = m.id.toString();
if (seen.has(id)) return false;
seen.add(id);
return true;
});
}, [activeServerMembers, ownMemberships]);
const [channels] = useTable(useMemo(() => tables.channel, []));
const [users, isUsersReady] = useTable(useMemo(() => tables.user, []));
const [allMessages] = useTable(
useMemo(
() =>
activeChannelId
? tables.message.where((r) => r.channelId.eq(activeChannelId))
: tables.message.where((r) => r.id.eq(-1n)),
[activeChannelId],
),
);
const [allThreads] = useTable(
useMemo(
() =>
activeChannelId
? tables.thread.where((r) => r.channelId.eq(activeChannelId))
: tables.thread.where((r) => r.id.eq(-1n)),
[activeChannelId],
),
);
const [voiceStates] = useTable(useMemo(() => tables.voice_state, []));
const [voiceActivity] = useTable(
useMemo(
() =>
connectedVoiceChannelId
? tables.voice_activity.where((va) =>
va.channelId.eq(connectedVoiceChannelId),
)
: tables.voice_activity.where((va) => va.identity.eq(Identity.zero())),
[connectedVoiceChannelId],
),
);
return {
servers: servers as readonly Types.Server[],
serverMembers: serverMembers as readonly Types.ServerMember[],
channels: (channels || []) as readonly Types.Channel[],
users: users as readonly Types.User[],
isUsersReady,
allMessages: (allMessages || []) as readonly Types.Message[],
allThreads: (allThreads || []) as readonly Types.Thread[],
voiceStates: voiceStates as readonly Types.VoiceState[],
voiceActivity: voiceActivity as readonly Types.VoiceActivity[],
};
};
-34
View File
@@ -1,34 +0,0 @@
import { useMemo } from "react";
import { Identity } from "spacetimedb";
import * as Types from "../../../module_bindings/types";
export const useVoiceStatus = (
identity: Identity | null,
voiceStates: readonly Types.VoiceState[],
channels: readonly Types.Channel[],
users: readonly Types.User[],
) => {
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 onlineUsers = useMemo(
() => users.filter((u) => u.online || identity?.isEqual(u.identity)),
[users, identity],
);
return {
currentVoiceState,
connectedVoiceChannel,
onlineUsers,
};
};
+3 -2
View File
@@ -1,3 +1,4 @@
export { useChat } from "./useChat"; // src/chat/services/index.ts
export { useWebRTC } from "./webrtc/useWebRTC"; export { ChatService } from "./chat.svelte";
export { WebRTCService } from "./webrtc/webrtc.svelte";
export type { WebRTCStats } from "./webrtc/types"; export type { WebRTCStats } from "./webrtc/types";
-273
View File
@@ -1,273 +0,0 @@
// src/chat/services/useChat.ts
import { useMemo, useCallback } from "react";
import { useSpacetimeDB } from "spacetimedb/react";
import { Identity } from "spacetimedb";
import * as Types from "../../module_bindings/types";
// Import specialized hooks
import { useNavigation } from "./hooks/useNavigation";
import { useSubscriptions } from "./hooks/useSubscriptions";
import { useActions } from "./hooks/useActions";
import { useVoiceStatus } from "./hooks/useVoiceStatus";
import { useDiscovery } from "./hooks/useDiscovery";
// Import utilities
import { getUsername, formatTime } from "../utils";
// Define interface for the hook's return value
export interface ChatState {
activeServerId: bigint | null;
activeChannelId: bigint | null;
activeThreadId: bigint | null;
messageText: string;
threadMessageText: string;
showCreateServerModal: boolean;
newServerName: string;
showCreateChannelModal: boolean;
newChannelName: string;
isVoiceChannel: boolean;
showSetNameModal: boolean;
newName: string;
showDiscoveryModal: boolean;
authError: string;
servers: readonly Types.Server[];
joinedServers: readonly Types.Server[];
availableServers: readonly Types.Server[];
channels: readonly Types.Channel[];
users: readonly Types.User[];
allMessages: readonly Types.Message[];
allThreads: readonly Types.Thread[];
voiceStates: readonly Types.VoiceState[];
voiceActivity: readonly Types.VoiceActivity[];
currentVoiceState: Types.VoiceState | undefined;
connectedVoiceChannel: Types.Channel | undefined;
onlineUsers: readonly Types.User[];
activeServerMembers: readonly Types.User[];
currentUser: Types.User | undefined;
activeServer: Types.Server | undefined;
activeChannel: Types.Channel | undefined;
activeThread: Types.Thread | undefined;
isActiveChannelVoice: boolean;
isActiveChannelText: boolean;
channelMessages: readonly Types.Message[];
threadMessages: readonly Types.Message[];
textChannels: readonly Types.Channel[];
voiceChannels: readonly Types.Channel[];
// Actions/Reducers
handleSendMessage: (e: React.FormEvent) => void;
handleSendThreadMessage: (e: React.FormEvent) => void;
handleCreateServer: (e: React.FormEvent) => void;
handleCreateChannel: (e: React.FormEvent) => void;
handleStartThread: (msg: Types.Message) => void;
handleJoinVoice: (channelId: bigint) => void;
handleLeaveVoice: () => void;
handleSetName: (e: React.FormEvent) => void;
handleJoinServer: (serverId: bigint) => void;
handleLeaveServer: (serverId: bigint) => void;
// Setters
setActiveServerId: (id: bigint | null) => void;
setActiveChannelId: (id: bigint | null) => void;
setActiveThreadId: (id: bigint | null) => void;
setMessageText: (text: string) => void;
setThreadMessageText: (text: string) => void;
setShowCreateServerModal: (show: boolean) => void;
setNewServerName: (name: string) => void;
setShowCreateChannelModal: (show: boolean) => void;
setNewChannelName: (name: string) => void;
setIsVoiceChannel: (isVoice: boolean) => void;
setShowSetNameModal: (show: boolean) => void;
setNewName: (name: string) => void;
setShowDiscoveryModal: (show: boolean) => void;
setAuthError: (error: string) => void;
// Derived status
isFullyAuthenticated: boolean;
// Reducers (Direct access if needed)
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;
joinVoiceReducer: (params: { channelId: bigint }) => void;
leaveVoiceReducer: () => void;
setNameReducer: (params: { name: string }) => void;
joinServerReducer: (params: { serverId: bigint }) => void;
leaveServerReducer: (params: { serverId: bigint }) => void;
// Helper functions
getUsername: (userIdentity: Identity | null) => string;
formatTime: (ts: any) => string;
}
export function useChat(): ChatState {
// 1. Get current identity from SpacetimeDB
const { identity } = useSpacetimeDB();
// 2. Setup navigation and subscriptions (order matters)
// We need initial subscriptions to get server list, etc. even before we have an active server.
const bootstrapSubs = useSubscriptions(identity || null, null, null);
const navigation = useNavigation(
identity || null,
bootstrapSubs.servers,
bootstrapSubs.channels,
bootstrapSubs.serverMembers,
);
const discovery = useDiscovery();
const actions = useActions(
navigation.activeServerId,
navigation.activeChannelId,
navigation.activeThreadId,
);
const voiceStatus = useVoiceStatus(
identity || null,
bootstrapSubs.voiceStates,
bootstrapSubs.channels,
bootstrapSubs.users,
);
// Now we can have specialized subscriptions based on navigation
// Note: SpacetimeDB useTable is smart about duplicate subscriptions.
// We call useSubscriptions again with the actual IDs to trigger indexed fetching.
const activeSubs = useSubscriptions(
identity || null,
navigation.activeServerId,
navigation.activeChannelId,
voiceStatus.connectedVoiceChannel?.id || null,
discovery.showDiscoveryModal,
);
// Derived Data (Moved back to main orchestrator for now as they use multiple hook results)
const isFullyAuthenticated = useMemo(() => {
const user = activeSubs.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);
return hasOidc || hasCreds;
}, [activeSubs.users, identity]);
const currentUser = useMemo(
() =>
activeSubs.users.find((u) =>
u.identity?.isEqual(identity || Identity.zero()),
),
[activeSubs.users, identity],
);
const channelMessages = useMemo(
() =>
activeSubs.allMessages
.filter(
(m) =>
m.channelId === navigation.activeChannelId &&
m.threadId === undefined,
)
.sort((a, b) =>
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1,
),
[activeSubs.allMessages, navigation.activeChannelId],
);
const threadMessages = useMemo(
() =>
activeSubs.allMessages
.filter((m) => m.threadId === navigation.activeThreadId)
.sort((a, b) =>
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1,
),
[activeSubs.allMessages, navigation.activeThreadId],
);
const activeThread = useMemo(
() => activeSubs.allThreads.find((t) => t.id === navigation.activeThreadId),
[activeSubs.allThreads, navigation.activeThreadId],
);
const isActiveChannelVoice = useMemo(
() => navigation.activeChannel?.kind.tag === "Voice",
[navigation.activeChannel],
);
const isActiveChannelText = useMemo(
() => navigation.activeChannel?.kind.tag === "Text",
[navigation.activeChannel],
);
const textChannels = useMemo(
() =>
activeSubs.channels.filter(
(c) =>
c.serverId === navigation.activeServerId && c.kind.tag === "Text",
),
[activeSubs.channels, navigation.activeServerId],
);
const voiceChannels = useMemo(
() =>
activeSubs.channels.filter(
(c) =>
c.serverId === navigation.activeServerId && c.kind.tag === "Voice",
),
[activeSubs.channels, navigation.activeServerId],
);
const activeServerMembers = useMemo(() => {
if (!navigation.activeServerId) return [];
const memberIdentities = new Set(
activeSubs.serverMembers
.filter((m) => m.serverId === navigation.activeServerId)
.map((m) => m.identity.toHexString()),
);
return activeSubs.users.filter((u) =>
memberIdentities.has(u.identity.toHexString()),
);
}, [activeSubs.serverMembers, activeSubs.users, navigation.activeServerId]);
return {
...navigation,
...activeSubs,
...actions,
...voiceStatus,
...discovery,
isFullyAuthenticated,
currentUser,
channelMessages,
threadMessages,
activeThread,
isActiveChannelVoice,
isActiveChannelText,
textChannels,
voiceChannels,
activeServerMembers,
voiceActivity: activeSubs.voiceActivity,
// Helper functions
getUsername: useCallback(
(userIdentity: Identity | null) =>
getUsername(userIdentity, activeSubs.users),
[activeSubs.users],
),
formatTime: useCallback((ts: any) => formatTime(ts), []),
};
}
@@ -0,0 +1,287 @@
import { Identity } from "spacetimedb";
import { tables, reducers } from "../../../module_bindings";
import { useTable, useReducer } from "spacetimedb/svelte";
import { PeerManagerService } from "./peer-manager.svelte";
import * as Types from "../../../module_bindings/types";
export class ChannelAudioWebRTCService {
voiceStates = $state<readonly Types.VoiceState[]>([]);
offers = $state<readonly Types.VoiceSdpOffer[]>([]);
answers = $state<readonly Types.VoiceSdpAnswer[]>([]);
iceCandidates = $state<readonly Types.VoiceIceCandidate[]>([]);
#sendSdpOffer = useReducer(reducers.sendVoiceSdpOffer);
#sendSdpAnswer = useReducer(reducers.sendVoiceSdpAnswer);
#sendIceCandidate = useReducer(reducers.sendVoiceIceCandidate);
// --- Signaling State ---
makingOffer = new Map<string, boolean>();
ignoreOffer = new Map<string, boolean>();
signalingQueue = new Map<string, Promise<void>>();
processedOffers = new Set<bigint>();
processedAnswers = new Set<bigint>();
processedCandidates = new Set<bigint>();
candidateQueue = new Map<string, any[]>();
identity = $state<Identity | null>(null);
connectedChannelId = $state<bigint | undefined>();
localStream = $state<MediaStream | null>(null);
isDeafened = $state(false);
peerManager: PeerManagerService;
constructor(
identity: Identity | null,
connectedChannelId: bigint | undefined,
localStream: MediaStream | null,
isDeafened: boolean
) {
this.identity = identity;
this.connectedChannelId = connectedChannelId;
this.localStream = localStream;
this.isDeafened = isDeafened;
const [vsStore] = useTable(tables.voice_state);
vsStore.subscribe(v => this.voiceStates = v);
const [offStore] = useTable(tables.voice_sdp_offer);
const [ansStore] = useTable(tables.voice_sdp_answer);
const [iceStore] = useTable(tables.voice_ice_candidate);
offStore.subscribe(v => this.offers = v);
ansStore.subscribe(v => this.answers = v);
iceStore.subscribe(v => this.iceCandidates = v);
this.peerManager = new PeerManagerService(
identity,
"voice",
isDeafened,
this.onNegotiationNeeded.bind(this),
this.onIceCandidate.bind(this)
);
// Sync state to peerManager
$effect(() => {
this.peerManager.identity = this.identity;
this.peerManager.isDeafened = this.isDeafened;
});
// Track Syncing
$effect(() => {
const audioTrack = this.localStream?.getAudioTracks()[0] || null;
// Accessing peers and peerStatuses to trigger effect on changes
const _statuses = this.peerManager.peerStatuses;
this.peerManager.peers.forEach(async (peer, peerIdHex) => {
const transceivers = peer.pc.getTransceivers();
if (transceivers[0] && transceivers[0].sender.track !== audioTrack) {
try {
console.log(`[WebRTC][voice] Syncing track for ${peerIdHex} (track: ${audioTrack?.id})`);
await transceivers[0].sender.replaceTrack(audioTrack);
// If we just attached a real track and we are stable,
// we might need to negotiate if the remote side didn't know we have a track.
// But replaceTrack usually doesn't need negotiation if the transceiver was already sendrecv.
if (audioTrack && peer.pc.signalingState === "stable") {
this.onNegotiationNeeded(peerIdHex, peer.pc);
}
} catch (e) {
console.error(`[WebRTC][voice] Track sync failed for ${peerIdHex}`, e);
}
}
});
});
// Lifecycle Mesh Management
$effect(() => {
if (!this.connectedChannelId || !this.identity) {
console.log(`[WebRTC][voice] Cleaning up channel state`);
this.peerManager.peers.forEach((_, id) => this.peerManager.closePeer(id));
this.processedOffers.clear();
this.processedAnswers.clear();
this.processedCandidates.clear();
this.makingOffer.clear();
this.ignoreOffer.clear();
this.signalingQueue.clear();
return;
}
const peersToConnect = new Set(
this.voiceStates
.filter(vs => vs.channelId === this.connectedChannelId && !vs.identity.isEqual(this.identity!))
.map(vs => vs.identity.toHexString())
);
peersToConnect.forEach(id => {
if (!this.peerManager.peers.has(id)) {
console.log(`[WebRTC][voice] Initiating mesh connection to ${id}`);
this.peerManager.createPeerConnection(id, [
this.localStream?.getAudioTracks()[0] || null,
]);
}
});
this.peerManager.peers.forEach((_, id) => {
if (!peersToConnect.has(id)) {
console.log(`[WebRTC][voice] Peer ${id} left channel, closing`);
this.peerManager.closePeer(id);
this.makingOffer.delete(id);
this.ignoreOffer.delete(id);
this.signalingQueue.delete(id);
}
});
});
// Signaling Processors
$effect(() => {
if (!this.connectedChannelId || !this.identity) return;
// Handle Offers
const myOffers = this.offers.filter(o =>
o.channelId === this.connectedChannelId &&
o.receiver.isEqual(this.identity!)
);
for (const offerRow of myOffers) {
if (this.processedOffers.has(offerRow.id)) continue;
this.processedOffers.add(offerRow.id);
this.handleOffer(offerRow);
}
// Handle Answers
const myAnswers = this.answers.filter(a =>
a.channelId === this.connectedChannelId &&
a.receiver.isEqual(this.identity!)
);
for (const answerRow of myAnswers) {
if (this.processedAnswers.has(answerRow.id)) continue;
this.processedAnswers.add(answerRow.id);
this.handleAnswer(answerRow);
}
// Handle ICE
const myIce = this.iceCandidates.filter(c =>
c.channelId === this.connectedChannelId &&
c.receiver.isEqual(this.identity!)
);
for (const candRow of myIce) {
if (this.processedCandidates.has(candRow.id)) continue;
this.processedCandidates.add(candRow.id);
this.handleIceCandidate(candRow);
}
});
}
enqueueSignalingTask(peerIdHex: string, task: () => Promise<void>) {
const currentQueue = this.signalingQueue.get(peerIdHex) || Promise.resolve();
const nextQueue = currentQueue.then(async () => {
try {
await task();
} catch (e) {
console.error(`[WebRTC][voice] Signaling task failed for ${peerIdHex}`, e);
}
});
this.signalingQueue.set(peerIdHex, nextQueue);
}
async drainCandidateQueue(peerIdHex: string, pc: RTCPeerConnection) {
const queue = this.candidateQueue.get(peerIdHex) || [];
if (queue.length === 0 || !pc.remoteDescription) return;
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);
}
}
this.candidateQueue.set(peerIdHex, []);
}
async onNegotiationNeeded(peerIdHex: string, pc: RTCPeerConnection) {
this.enqueueSignalingTask(peerIdHex, async () => {
if (!this.connectedChannelId || pc.signalingState !== "stable" || this.makingOffer.get(peerIdHex)) return;
try {
this.makingOffer.set(peerIdHex, true);
await pc.setLocalDescription();
this.#sendSdpOffer({
receiver: Identity.fromString(peerIdHex),
sdp: JSON.stringify(pc.localDescription),
channelId: this.connectedChannelId,
});
} finally {
this.makingOffer.set(peerIdHex, false);
}
});
}
onIceCandidate(peerIdHex: string, candidate: RTCIceCandidate) {
if (this.connectedChannelId) {
this.#sendIceCandidate({
receiver: Identity.fromString(peerIdHex),
candidate: JSON.stringify(candidate),
channelId: this.connectedChannelId,
});
}
}
handleOffer(offerRow: Types.VoiceSdpOffer) {
const peerIdHex = offerRow.sender.toHexString();
this.enqueueSignalingTask(peerIdHex, async () => {
const pc = this.peerManager.createPeerConnection(peerIdHex);
if (!pc) return;
try {
const isPolite = this.identity!.toHexString() < peerIdHex;
const offerCollision = pc.signalingState !== "stable" || !!this.makingOffer.get(peerIdHex);
const ignoreOffer = !isPolite && offerCollision;
this.ignoreOffer.set(peerIdHex, ignoreOffer);
if (ignoreOffer) return;
if (offerCollision) await pc.setLocalDescription({ type: "rollback" });
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(offerRow.sdp)));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.#sendSdpAnswer({
receiver: offerRow.sender,
sdp: JSON.stringify(answer),
channelId: this.connectedChannelId!,
});
await this.drainCandidateQueue(peerIdHex, pc);
} catch (e) {
console.error(`[WebRTC][voice] Failed to handle offer from ${peerIdHex}`, e);
}
});
}
handleAnswer(answerRow: Types.VoiceSdpAnswer) {
const peerIdHex = answerRow.sender.toHexString();
this.enqueueSignalingTask(peerIdHex, async () => {
const peer = this.peerManager.getPeer(peerIdHex);
if (!peer) return;
try {
await peer.pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(answerRow.sdp)));
await this.drainCandidateQueue(peerIdHex, peer.pc);
} catch (e) {
console.error(`[WebRTC][voice] Failed to handle answer from ${peerIdHex}`, e);
}
});
}
handleIceCandidate(candRow: Types.VoiceIceCandidate) {
const peerIdHex = candRow.sender.toHexString();
this.enqueueSignalingTask(peerIdHex, async () => {
const pc = this.peerManager.createPeerConnection(peerIdHex);
if (!pc) return;
try {
const candidate = JSON.parse(candRow.candidate);
if (pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else if (!this.ignoreOffer.get(peerIdHex)) {
const queue = this.candidateQueue.get(peerIdHex) || [];
queue.push(candidate);
this.candidateQueue.set(peerIdHex, queue);
}
} catch (e) {
console.error(`[WebRTC][voice] Failed to handle ICE from ${peerIdHex}`, e);
}
});
}
}
@@ -0,0 +1,140 @@
import { reducers } from "../../../module_bindings";
import { useReducer } from "spacetimedb/svelte";
export class LocalMediaService {
localStream = $state<MediaStream | null>(null);
localScreenStream = $state<MediaStream | null>(null);
isMuted = $state(false);
isDeafened = $state(false);
isTalking = $state(false);
#setTalking = useReducer(reducers.setTalking);
#setSharingScreen = useReducer(reducers.setSharingScreen);
connectedChannelId: bigint | undefined = $state();
constructor(connectedChannelId: bigint | undefined) {
this.connectedChannelId = connectedChannelId;
// Handle Mute/Deafen effect on tracks
$effect(() => {
if (this.localStream) {
this.localStream.getAudioTracks().forEach((track) => {
track.enabled = !this.isMuted && !this.isDeafened;
});
}
});
// Voice Activity Detection
$effect(() => {
if (!this.localStream || this.isMuted || this.isDeafened || !this.connectedChannelId) {
if (this.isTalking) {
this.#setTalking({ talking: false, channelId: this.connectedChannelId || 0n });
this.isTalking = false;
}
return;
}
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(this.localStream);
analyser.fftSize = 256;
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const threshold = 15;
let silenceFrames = 0;
const maxSilenceFrames = 10;
const checkAudio = setInterval(() => {
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
const average = sum / dataArray.length;
if (average > threshold) {
silenceFrames = 0;
if (!this.isTalking) {
this.#setTalking({ talking: true, channelId: this.connectedChannelId! });
this.isTalking = true;
}
} else {
silenceFrames++;
if (silenceFrames > maxSilenceFrames && this.isTalking) {
this.#setTalking({ talking: false, channelId: this.connectedChannelId! });
this.isTalking = false;
}
}
}, 50);
return () => {
clearInterval(checkAudio);
audioContext.close();
};
});
}
get isSharingScreen() {
return !!this.localScreenStream;
}
toggleMute = () => {
this.isMuted = !this.isMuted;
};
toggleDeafen = () => {
this.isDeafened = !this.isDeafened;
};
requestMic = async () => {
if (this.localStream) return;
try {
console.log("[WebRTC] Requesting mic permission...");
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
this.localStream = stream;
} catch (err) {
console.error("[WebRTC] Failed to get mic:", err);
}
};
releaseMic = () => {
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop());
this.localStream = null;
}
};
startScreenShare = async (onTrackReady: (track: MediaStreamTrack) => void) => {
try {
console.log("[WebRTC] Requesting screen share...");
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
});
this.localScreenStream = stream;
this.#setSharingScreen({ sharing: true });
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack) {
onTrackReady(videoTrack);
videoTrack.onended = () =>
this.stopScreenShare(() => onTrackReady(null as any));
}
} catch (err) {
console.error("[WebRTC] Failed to start screen share:", err);
}
};
stopScreenShare = (onTrackCleared: (track: MediaStreamTrack | null) => void) => {
if (this.localScreenStream) {
this.localScreenStream
.getTracks()
.forEach((track) => track.stop());
this.localScreenStream = null;
this.#setSharingScreen({ sharing: false });
onTrackCleared(null);
}
};
}
@@ -0,0 +1,317 @@
import { Identity } from "spacetimedb";
import type { Peer, WebRTCStats } from "./types";
const ICE_SERVERS: RTCConfiguration = {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
};
export class PeerManagerService {
peers = $state<Map<string, Peer>>(new Map());
peerStatuses = $state<Map<string, string>>(new Map());
peerStats = $state<Map<string, WebRTCStats>>(new Map());
peerAudioPreferences = new Map<string, { volume: number; muted: boolean }>();
audioContext: AudioContext | null = null;
identity = $state<Identity | null>(null);
mediaType = $state<"voice" | "screen">("voice");
isDeafened = $state(false);
onNegotiationNeeded: (peerIdHex: string, pc: RTCPeerConnection) => void;
onIceCandidate: (peerIdHex: string, candidate: RTCIceCandidate) => void;
constructor(
identity: Identity | null,
mediaType: "voice" | "screen",
isDeafened: boolean,
onNegotiationNeeded: (peerIdHex: string, pc: RTCPeerConnection) => void,
onIceCandidate: (peerIdHex: string, candidate: RTCIceCandidate) => void
) {
this.identity = identity;
this.mediaType = mediaType;
this.isDeafened = isDeafened;
this.onNegotiationNeeded = onNegotiationNeeded;
this.onIceCandidate = onIceCandidate;
$effect(() => {
if (this.mediaType === "voice") {
this.peers.forEach((peer, peerIdHex) => {
const pref = this.peerAudioPreferences.get(peerIdHex) || {
volume: 1,
muted: false,
};
if (peer.gainNode) {
peer.gainNode.gain.value = this.isDeafened || pref.muted ? 0 : pref.volume;
} else if (peer.audio) {
peer.audio.muted = this.isDeafened || pref.muted;
}
});
}
});
$effect(() => {
if (this.peers.size === 0) {
if (this.peerStats.size > 0) {
this.peerStats = new Map();
}
return;
}
const interval = setInterval(async () => {
const newStats = new Map(this.peerStats);
for (const [peerIdHex, peer] of this.peers.entries()) {
try {
const stats = await peer.pc.getStats();
const prevStats = this.peerStats.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(),
};
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;
target.bytesReceived = report.bytesReceived || 0;
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;
}
if (prevStats) {
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);
}
}
}
}
});
newStats.set(peerIdHex, currentStats);
} catch (e) {
console.warn(`[WebRTC][${this.mediaType}] Failed to get stats for ${peerIdHex}`, e);
}
}
this.peerStats = newStats;
}, 2000);
return () => clearInterval(interval);
});
}
getAudioContext = () => {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
}
if (this.audioContext.state === "suspended") {
this.audioContext.resume();
}
return this.audioContext;
};
setPeerAudioPreference = (peerIdHex: string, preference: { volume?: number; muted?: boolean }) => {
const current = this.peerAudioPreferences.get(peerIdHex) || {
volume: 1,
muted: false,
};
const next = { ...current, ...preference };
this.peerAudioPreferences.set(peerIdHex, next);
const ctx = this.audioContext;
if (ctx && ctx.state === "suspended") {
ctx.resume();
}
const peer = this.peers.get(peerIdHex);
if (peer) {
if (peer.gainNode) {
peer.gainNode.gain.value = this.isDeafened || next.muted ? 0 : next.volume;
} else if (peer.audio) {
peer.audio.volume = Math.min(1, next.volume);
peer.audio.muted = this.isDeafened || next.muted;
}
}
};
closePeer = (peerIdHex: string) => {
const peer = this.peers.get(peerIdHex);
if (peer) {
console.log(`[WebRTC][${this.mediaType}] Closing peer connection for ${peerIdHex}`);
peer.pc.close();
if (peer.gainNode) {
peer.gainNode.disconnect();
}
if (peer.audio) {
peer.audio.pause();
peer.audio.srcObject = null;
}
const nextPeers = new Map(this.peers);
nextPeers.delete(peerIdHex);
this.peers = nextPeers;
const nextStatuses = new Map(this.peerStatuses);
nextStatuses.delete(peerIdHex);
this.peerStatuses = nextStatuses;
}
};
createPeerConnection = (peerIdHex: string, initialTracks: (MediaStreamTrack | null)[] = []) => {
if (this.peers.has(peerIdHex)) return this.peers.get(peerIdHex)!.pc;
if (this.identity && peerIdHex === this.identity.toHexString()) {
console.warn(`[WebRTC][${this.mediaType}] Attempted to create a PeerConnection to self (${peerIdHex}). Ignoring.`);
return null as any;
}
console.log(`[WebRTC][${this.mediaType}] Creating new PeerConnection for ${peerIdHex}`);
const pc = new RTCPeerConnection(ICE_SERVERS);
pc.onnegotiationneeded = () => {
console.log(`[WebRTC][${this.mediaType}] onnegotiationneeded fired for ${peerIdHex}`);
this.onNegotiationNeeded(peerIdHex, pc);
};
pc.onicecandidate = (event) => {
if (event.candidate) {
this.onIceCandidate(peerIdHex, event.candidate);
}
};
pc.oniceconnectionstatechange = () => {
console.log(`[WebRTC][${this.mediaType}] ICE state for ${peerIdHex}: ${pc.iceConnectionState}`);
const nextStatuses = new Map(this.peerStatuses);
nextStatuses.set(peerIdHex, `${pc.iceConnectionState}/${pc.signalingState}`);
this.peerStatuses = nextStatuses;
if (pc.iceConnectionState === "failed") {
console.log(`[WebRTC][${this.mediaType}] ICE failed for ${peerIdHex}, closing peer for retry`);
this.closePeer(peerIdHex);
}
};
pc.onsignalingstatechange = () => {
console.log(`[WebRTC][${this.mediaType}] Signaling state for ${peerIdHex}: ${pc.signalingState}`);
const nextStatuses = new Map(this.peerStatuses);
nextStatuses.set(peerIdHex, `${pc.iceConnectionState}/${pc.signalingState}`);
this.peerStatuses = nextStatuses;
};
pc.onconnectionstatechange = () => {
console.log(`[WebRTC][${this.mediaType}] Connection state for ${peerIdHex}: ${pc.connectionState}`);
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
console.log(`[WebRTC][${this.mediaType}] Connection ${pc.connectionState} for ${peerIdHex}, cleaning up`);
this.closePeer(peerIdHex);
}
};
pc.ontrack = (event) => {
console.log(`[WebRTC][${this.mediaType}] Received track from ${peerIdHex}: ${event.track.kind} (id: ${event.track.id})`);
const nextPeers = new Map(this.peers);
const existingPeer = { ...(nextPeers.get(peerIdHex) || { pc }) };
if (event.track.kind === "audio") {
if (this.mediaType === "voice") {
const pref = this.peerAudioPreferences.get(peerIdHex) || {
volume: 1,
muted: false,
};
try {
const ctx = this.getAudioContext();
const stream = new MediaStream([event.track]);
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.muted = true;
}
existingPeer.audio.srcObject = stream;
existingPeer.audio.play().catch((err) => {
if (err.name !== "AbortError")
console.warn(`[WebRTC][voice] Dummy audio play failed for ${peerIdHex}`, err);
});
const source = ctx.createMediaStreamSource(stream);
const gainNode = ctx.createGain();
gainNode.gain.value = this.isDeafened || pref.muted ? 0 : pref.volume;
source.connect(gainNode);
gainNode.connect(ctx.destination);
existingPeer.gainNode = gainNode;
console.log(`[WebRTC][voice] Web Audio graph connected for ${peerIdHex} (volume: ${pref.volume})`);
} catch (e) {
console.error(`[WebRTC][voice] Failed to setup Web Audio for ${peerIdHex}, falling back to HTMLAudioElement`, e);
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.autoplay = true;
const pref = this.peerAudioPreferences.get(peerIdHex) || {
volume: 1,
muted: false,
};
existingPeer.audio.volume = Math.min(1, pref.volume);
existingPeer.audio.muted = this.isDeafened || pref.muted;
}
const stream = new MediaStream([event.track]);
existingPeer.audio.srcObject = stream;
existingPeer.audio.play().catch((err) => {
if (err.name !== "AbortError")
console.error(`[WebRTC][voice] Error playing audio for ${peerIdHex}`, err);
});
}
} else {
const currentStream = existingPeer.videoStream || new MediaStream();
if (!currentStream.getTracks().find((t) => t.id === event.track.id)) {
currentStream.addTrack(event.track);
}
existingPeer.videoStream = new MediaStream(currentStream.getTracks());
}
} else if (event.track.kind === "video") {
const currentStream = existingPeer.videoStream || new MediaStream();
if (!currentStream.getTracks().find((t) => t.id === event.track.id)) {
currentStream.addTrack(event.track);
}
existingPeer.videoStream = new MediaStream(currentStream.getTracks());
}
nextPeers.set(peerIdHex, existingPeer);
this.peers = nextPeers;
};
if (this.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][${this.mediaType}] Attaching initial track ${i} to ${peerIdHex}`);
transceivers[i].sender.replaceTrack(track);
}
});
const nextPeers = new Map(this.peers);
nextPeers.set(peerIdHex, { pc });
this.peers = nextPeers;
return pc;
};
getPeer = (peerIdHex: string) => this.peers.get(peerIdHex);
}
@@ -0,0 +1,284 @@
import { Identity } from "spacetimedb";
import { tables, reducers } from "../../../module_bindings";
import { useTable, useReducer } from "spacetimedb/svelte";
import { PeerManagerService } from "./peer-manager.svelte";
import * as Types from "../../../module_bindings/types";
export class ScreenSharingWebRTCService {
watching = $state<readonly Types.Watching[]>([]);
offers = $state<readonly Types.ScreenSdpOffer[]>([]);
answers = $state<readonly Types.ScreenSdpAnswer[]>([]);
iceCandidates = $state<readonly Types.ScreenIceCandidate[]>([]);
#sendSdpOffer = useReducer(reducers.sendScreenSdpOffer);
#sendSdpAnswer = useReducer(reducers.sendScreenSdpAnswer);
#sendIceCandidate = useReducer(reducers.sendScreenIceCandidate);
// --- Signaling State ---
makingOffer = new Map<string, boolean>();
ignoreOffer = new Map<string, boolean>();
signalingQueue = new Map<string, Promise<void>>();
processedOffers = new Set<bigint>();
processedAnswers = new Set<bigint>();
processedCandidates = new Set<bigint>();
candidateQueue = new Map<string, any[]>();
identity = $state<Identity | null>(null);
connectedChannelId = $state<bigint | undefined>();
localScreenStream = $state<MediaStream | null>(null);
peerManager: PeerManagerService;
constructor(
identity: Identity | null,
connectedChannelId: bigint | undefined,
localScreenStream: MediaStream | null
) {
this.identity = identity;
this.connectedChannelId = connectedChannelId;
this.localScreenStream = localScreenStream;
const [wStore] = useTable(tables.watching);
wStore.subscribe(v => this.watching = v);
const [offStore] = useTable(tables.screen_sdp_offer);
const [ansStore] = useTable(tables.screen_sdp_answer);
const [iceStore] = useTable(tables.screen_ice_candidate);
offStore.subscribe(v => this.offers = v);
ansStore.subscribe(v => this.answers = v);
iceStore.subscribe(v => this.iceCandidates = v);
this.peerManager = new PeerManagerService(
identity,
"screen",
false,
this.onNegotiationNeeded.bind(this),
this.onIceCandidate.bind(this)
);
// Sync state to peerManager
$effect(() => {
this.peerManager.identity = this.identity;
});
// Track Syncing
$effect(() => {
const videoTrack = this.localScreenStream?.getVideoTracks()[0] || null;
const audioTrack = this.localScreenStream?.getAudioTracks()[0] || null;
// Accessing peers and peerStatuses to trigger effect on changes
const _statuses = this.peerManager.peerStatuses;
this.peerManager.peers.forEach(async (peer, peerIdHex) => {
const transceivers = peer.pc.getTransceivers();
let changed = false;
if (transceivers[0] && transceivers[0].sender.track !== videoTrack) {
console.log(`[WebRTC][screen] Syncing video track for ${peerIdHex} (track: ${videoTrack?.id})`);
await transceivers[0].sender.replaceTrack(videoTrack);
changed = true;
}
if (transceivers[1] && transceivers[1].sender.track !== audioTrack) {
console.log(`[WebRTC][screen] Syncing audio track for ${peerIdHex} (track: ${audioTrack?.id})`);
await transceivers[1].sender.replaceTrack(audioTrack);
changed = true;
}
if (changed && peer.pc.signalingState === "stable") {
this.onNegotiationNeeded(peerIdHex, peer.pc);
}
});
});
// Lifecycle Mesh Management
$effect(() => {
if (!this.connectedChannelId || !this.identity) {
console.log(`[WebRTC][screen] Cleaning up screen state`);
this.peerManager.peers.forEach((_, id) => this.peerManager.closePeer(id));
this.processedOffers.clear();
this.processedAnswers.clear();
this.processedCandidates.clear();
this.makingOffer.clear();
this.ignoreOffer.clear();
this.signalingQueue.clear();
return;
}
const screenPeersToConnect = new Set<string>();
this.watching.forEach((w) => {
if (w.channelId === this.connectedChannelId) {
if (w.watcher.isEqual(this.identity!)) screenPeersToConnect.add(w.watchee.toHexString());
else if (w.watchee.isEqual(this.identity!)) screenPeersToConnect.add(w.watcher.toHexString());
}
});
screenPeersToConnect.forEach(id => {
if (!this.peerManager.peers.has(id)) {
console.log(`[WebRTC][screen] Connecting to watched peer ${id}`);
this.peerManager.createPeerConnection(id, [
this.localScreenStream?.getVideoTracks()[0] || null,
this.localScreenStream?.getAudioTracks()[0] || null,
]);
}
});
this.peerManager.peers.forEach((_, id) => {
if (!screenPeersToConnect.has(id)) {
console.log(`[WebRTC][screen] Peer ${id} no longer watched, closing`);
this.peerManager.closePeer(id);
this.makingOffer.delete(id);
this.ignoreOffer.delete(id);
this.signalingQueue.delete(id);
}
});
});
// Signaling Processors
$effect(() => {
if (!this.connectedChannelId || !this.identity) return;
const myOffers = this.offers.filter(o =>
o.channelId === this.connectedChannelId &&
o.receiver.isEqual(this.identity!)
);
for (const offerRow of myOffers) {
if (this.processedOffers.has(offerRow.id)) continue;
this.processedOffers.add(offerRow.id);
this.handleOffer(offerRow);
}
const myAnswers = this.answers.filter(a =>
a.channelId === this.connectedChannelId &&
a.receiver.isEqual(this.identity!)
);
for (const answerRow of myAnswers) {
if (this.processedAnswers.has(answerRow.id)) continue;
this.processedAnswers.add(answerRow.id);
this.handleAnswer(answerRow);
}
const myIce = this.iceCandidates.filter(c =>
c.channelId === this.connectedChannelId &&
c.receiver.isEqual(this.identity!)
);
for (const candRow of myIce) {
if (this.processedCandidates.has(candRow.id)) continue;
this.processedCandidates.add(candRow.id);
this.handleIceCandidate(candRow);
}
});
}
enqueueSignalingTask(peerIdHex: string, task: () => Promise<void>) {
const currentQueue = this.signalingQueue.get(peerIdHex) || Promise.resolve();
const nextQueue = currentQueue.then(async () => {
try {
await task();
} catch (e) {
console.error(`[WebRTC][screen] Signaling task failed for ${peerIdHex}`, e);
}
});
this.signalingQueue.set(peerIdHex, nextQueue);
}
async drainCandidateQueue(peerIdHex: string, pc: RTCPeerConnection) {
const queue = this.candidateQueue.get(peerIdHex) || [];
if (queue.length === 0 || !pc.remoteDescription) return;
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);
}
}
this.candidateQueue.set(peerIdHex, []);
}
async onNegotiationNeeded(peerIdHex: string, pc: RTCPeerConnection) {
this.enqueueSignalingTask(peerIdHex, async () => {
if (!this.connectedChannelId || pc.signalingState !== "stable" || this.makingOffer.get(peerIdHex)) return;
try {
this.makingOffer.set(peerIdHex, true);
await pc.setLocalDescription();
this.#sendSdpOffer({
receiver: Identity.fromString(peerIdHex),
sdp: JSON.stringify(pc.localDescription),
channelId: this.connectedChannelId,
});
} finally {
this.makingOffer.set(peerIdHex, false);
}
});
}
onIceCandidate(peerIdHex: string, candidate: RTCIceCandidate) {
if (this.connectedChannelId) {
this.#sendIceCandidate({
receiver: Identity.fromString(peerIdHex),
candidate: JSON.stringify(candidate),
channelId: this.connectedChannelId,
});
}
}
handleOffer(offerRow: Types.ScreenSdpOffer) {
const peerIdHex = offerRow.sender.toHexString();
this.enqueueSignalingTask(peerIdHex, async () => {
const pc = this.peerManager.createPeerConnection(peerIdHex);
if (!pc) return;
try {
const isPolite = this.identity!.toHexString() < peerIdHex;
const offerCollision = pc.signalingState !== "stable" || !!this.makingOffer.get(peerIdHex);
const ignoreOffer = !isPolite && offerCollision;
this.ignoreOffer.set(peerIdHex, ignoreOffer);
if (ignoreOffer) return;
if (offerCollision) await pc.setLocalDescription({ type: "rollback" });
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(offerRow.sdp)));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.#sendSdpAnswer({
receiver: offerRow.sender,
sdp: JSON.stringify(answer),
channelId: this.connectedChannelId!,
});
await this.drainCandidateQueue(peerIdHex, pc);
} catch (e) {
console.error(`[WebRTC][screen] Failed to handle offer from ${peerIdHex}`, e);
}
});
}
handleAnswer(answerRow: Types.ScreenSdpAnswer) {
const peerIdHex = answerRow.sender.toHexString();
this.enqueueSignalingTask(peerIdHex, async () => {
const peer = this.peerManager.getPeer(peerIdHex);
if (!peer) return;
try {
await peer.pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(answerRow.sdp)));
await this.drainCandidateQueue(peerIdHex, peer.pc);
} catch (e) {
console.error(`[WebRTC][screen] Failed to handle answer from ${peerIdHex}`, e);
}
});
}
handleIceCandidate(candRow: Types.ScreenIceCandidate) {
const peerIdHex = candRow.sender.toHexString();
this.enqueueSignalingTask(peerIdHex, async () => {
const pc = this.peerManager.createPeerConnection(peerIdHex);
if (!pc) return;
try {
const candidate = JSON.parse(candRow.candidate);
if (pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else if (!this.ignoreOffer.get(peerIdHex)) {
const queue = this.candidateQueue.get(peerIdHex) || [];
queue.push(candidate);
this.candidateQueue.set(peerIdHex, queue);
}
} catch (e) {
console.error(`[WebRTC][screen] Failed to handle ICE from ${peerIdHex}`, e);
}
});
}
}
@@ -1,381 +0,0 @@
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(
useMemo(
() =>
identity
? tables.voice_sdp_offer.where((o) => o.receiver.eq(identity))
: tables.voice_sdp_offer.where(() => false),
[identity],
),
);
const [answers] = useTable(
useMemo(
() =>
identity
? tables.voice_sdp_answer.where((o) => o.receiver.eq(identity))
: tables.voice_sdp_answer.where(() => false),
[identity],
),
);
const [iceCandidates] = useTable(
useMemo(
() =>
identity
? tables.voice_ice_candidate.where((o) => o.receiver.eq(identity))
: tables.voice_ice_candidate.where(() => false),
[identity],
),
);
const sendSdpOffer = useReducer(reducers.sendVoiceSdpOffer);
const sendSdpAnswer = useReducer(reducers.sendVoiceSdpAnswer);
const sendIceCandidate = useReducer(reducers.sendVoiceIceCandidate);
// --- Refs for Coordination ---
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
const ignoreOfferRef = useRef<Map<string, boolean>>(new Map());
const signalingQueueRef = useRef<Map<string, Promise<void>>>(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]);
// --- Helper: Serialized Signaling Queue ---
const enqueueSignalingTask = useCallback(
(peerIdHex: string, task: () => Promise<void>) => {
const currentQueue =
signalingQueueRef.current.get(peerIdHex) || Promise.resolve();
const nextQueue = currentQueue.then(async () => {
try {
await task();
} catch (e) {
console.error(
`[WebRTC][voice] Signaling task failed for ${peerIdHex}`,
e,
);
}
});
signalingQueueRef.current.set(peerIdHex, nextQueue);
},
[],
);
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) => {
enqueueSignalingTask(peerIdHex, async () => {
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,
});
} finally {
makingOfferRef.current.set(peerIdHex, false);
}
});
},
[sendSdpOffer, enqueueSignalingTask],
);
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,
);
// --- Signaling Processors ---
useEffect(() => {
if (!connectedChannelId || !identity) return;
// 1. Handle Offers
const myOffers = offers.filter(
(o) =>
o.receiver.isEqual(identity) &&
!o.sender.isEqual(identity) &&
o.channelId === connectedChannelId,
);
for (const offerRow of myOffers) {
if (processedOffersRef.current.has(offerRow.id)) continue;
processedOffersRef.current.add(offerRow.id);
const peerIdHex = offerRow.sender.toHexString();
enqueueSignalingTask(peerIdHex, async () => {
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) return;
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 glare from ${peerIdHex} (impolite)`,
);
return;
}
if (offerCollision) {
console.log(
`[WebRTC][voice] Rolling back for offer glare from ${peerIdHex} (polite)`,
);
await pc.setLocalDescription({ type: "rollback" as RTCSdpType });
}
console.log(`[WebRTC][voice] Processing offer from ${peerIdHex}`);
await pc.setRemoteDescription(
new RTCSessionDescription(JSON.parse(offerRow.sdp)),
);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendSdpAnswer({
receiver: offerRow.sender,
sdp: JSON.stringify(answer),
channelId: connectedChannelId,
});
await drainCandidateQueue(peerIdHex, pc);
} catch (e) {
console.error(
`[WebRTC][voice] Failed to handle offer from ${peerIdHex}`,
e,
);
}
});
}
// 2. Handle Answers
const myAnswers = answers.filter(
(a) =>
a.receiver.isEqual(identity) &&
!a.sender.isEqual(identity) &&
a.channelId === connectedChannelId,
);
for (const answerRow of myAnswers) {
if (processedAnswersRef.current.has(answerRow.id)) continue;
processedAnswersRef.current.add(answerRow.id);
const peerIdHex = answerRow.sender.toHexString();
enqueueSignalingTask(peerIdHex, async () => {
const peer = peerManager.getPeer(peerIdHex);
if (!peer) return;
try {
console.log(`[WebRTC][voice] Processing answer from ${peerIdHex}`);
await peer.pc.setRemoteDescription(
new RTCSessionDescription(JSON.parse(answerRow.sdp)),
);
await drainCandidateQueue(peerIdHex, peer.pc);
} catch (e) {
console.error(
`[WebRTC][voice] Failed to handle answer from ${peerIdHex}`,
e,
);
}
});
}
// 3. Handle ICE Candidates
const myCandidates = iceCandidates.filter(
(c) =>
c.receiver.isEqual(identity) &&
!c.sender.isEqual(identity) &&
c.channelId === connectedChannelId,
);
for (const candRow of myCandidates) {
if (processedCandidatesRef.current.has(candRow.id)) continue;
processedCandidatesRef.current.add(candRow.id);
const peerIdHex = candRow.sender.toHexString();
enqueueSignalingTask(peerIdHex, async () => {
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) return;
try {
const candidate = JSON.parse(candRow.candidate);
const ignoreOffer = ignoreOfferRef.current.get(peerIdHex) || false;
if (pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else if (!ignoreOffer) {
const queue = candidateQueueRef.current.get(peerIdHex) || [];
queue.push(candidate);
candidateQueueRef.current.set(peerIdHex, queue);
}
} catch (e) {
console.error(
`[WebRTC][voice] Failed to handle ICE from ${peerIdHex}`,
e,
);
}
});
}
}, [
offers,
answers,
iceCandidates,
connectedChannelId,
identity,
peerManager,
sendSdpAnswer,
enqueueSignalingTask,
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 track for ${peerIdHex}`);
await transceivers[0].sender.replaceTrack(audioTrack);
if (peer.pc.signalingState === "stable")
onNegotiationNeeded(peerIdHex, peer.pc);
} catch (e) {
console.error(
`[WebRTC][voice] Track sync failed for ${peerIdHex}`,
e,
);
}
}
});
}, [
localStream,
peerManager.peers,
onNegotiationNeeded,
peerManager.peersRef,
]);
// --- Lifecycle Mesh Management ---
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 channel state`);
peerManager.peersRef.current.forEach((_, id) =>
peerManager.closePeer(id),
);
processedOffersRef.current.clear();
processedAnswersRef.current.clear();
processedCandidatesRef.current.clear();
makingOfferRef.current.clear();
ignoreOfferRef.current.clear();
signalingQueueRef.current.clear();
return;
}
voicePeersToConnect.forEach((id) => {
if (!peerManager.peersRef.current.has(id)) {
console.log(`[WebRTC][voice] Initiating mesh connection to ${id}`);
peerManager.createPeerConnection(id, [
localStream?.getAudioTracks()[0] || null,
]);
}
});
peerManager.peersRef.current.forEach((_, id) => {
if (!voicePeersToConnect.has(id)) {
console.log(`[WebRTC][voice] Peer ${id} left channel, closing`);
peerManager.closePeer(id);
makingOfferRef.current.delete(id);
ignoreOfferRef.current.delete(id);
signalingQueueRef.current.delete(id);
}
});
}, [
voicePeersToConnect,
connectedChannelId,
identity,
peerManager,
localStream,
]);
return {
peerStatuses: peerManager.peerStatuses,
peerStats: peerManager.peerStats,
setPeerAudioPreference: peerManager.setPeerAudioPreference,
peers: peerManager.peers,
};
};
-161
View File
@@ -1,161 +0,0 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useReducer } from "spacetimedb/react";
import { reducers } from "../../../module_bindings";
export const useLocalMedia = (connectedChannelId: bigint | undefined) => {
const [localStream, setLocalStream] = 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);
const localStreamRef = useRef<MediaStream | null>(null);
const localScreenStreamRef = useRef<MediaStream | null>(null);
const isTalkingRef = useRef(false);
const setTalking = useReducer(reducers.setTalking);
const setSharingScreen = useReducer(reducers.setSharingScreen);
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,
});
setLocalStream(stream);
localStreamRef.current = stream;
} catch (err) {
console.error("[WebRTC] Failed to get mic:", err);
}
}, []);
const releaseMic = useCallback(() => {
if (localStreamRef.current) {
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 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);
}
},
[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) => {
track.enabled = !isMuted && !isDeafened;
});
}
}, [isMuted, isDeafened]);
// Voice Activity Detection
useEffect(() => {
if (!localStream || isMuted || isDeafened || !connectedChannelId) {
if (isTalkingRef.current) {
setTalking({ talking: false, channelId: connectedChannelId || 0n });
isTalkingRef.current = false;
setIsTalking(false);
}
return;
}
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(localStream);
analyser.fftSize = 256;
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const threshold = 15;
let silenceFrames = 0;
const maxSilenceFrames = 10;
const checkAudio = setInterval(() => {
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
const average = sum / dataArray.length;
if (average > threshold) {
silenceFrames = 0;
if (!isTalkingRef.current) {
setTalking({ talking: true, channelId: connectedChannelId });
isTalkingRef.current = true;
setIsTalking(true);
}
} else {
silenceFrames++;
if (silenceFrames > maxSilenceFrames && isTalkingRef.current) {
setTalking({ talking: false, channelId: connectedChannelId });
isTalkingRef.current = false;
setIsTalking(false);
}
}
}, 50);
return () => {
clearInterval(checkAudio);
audioContext.close();
};
}, [localStream, setTalking, isMuted, isDeafened, connectedChannelId]);
return {
localStream,
localScreenStream,
isMuted,
isDeafened,
isTalking,
isSharingScreen: !!localScreenStream,
toggleMute,
toggleDeafen,
startScreenShare,
stopScreenShare,
requestMic,
releaseMic,
localStreamRef,
localScreenStreamRef,
};
};
-435
View File
@@ -1,435 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Identity } from "spacetimedb";
import { Peer, WebRTCStats } from "./types";
const ICE_SERVERS: RTCConfiguration = {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
};
export const usePeerManager = (
identity: Identity | null,
mediaType: "voice" | "screen",
isDeafened: boolean, // Only relevant for voice
onNegotiationNeeded: (peerIdHex: string, pc: RTCPeerConnection) => 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 peersRef = useRef<Map<string, Peer>>(new Map());
const peerStatsRef = useRef<Map<string, WebRTCStats>>(new Map());
const peerAudioPreferencesRef = useRef<
Map<string, { volume: number; muted: boolean }>
>(new Map());
const audioContextRef = useRef<AudioContext | null>(null);
const getAudioContext = useCallback(() => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext ||
(window as any).webkitAudioContext)();
}
if (audioContextRef.current.state === "suspended") {
audioContextRef.current.resume();
}
return audioContextRef.current;
}, []);
// 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);
useEffect(() => {
onNegotiationNeededRef.current = onNegotiationNeeded;
}, [onNegotiationNeeded]);
useEffect(() => {
onIceCandidateRef.current = onIceCandidate;
}, [onIceCandidate]);
useEffect(() => {
isDeafenedRef.current = isDeafened;
}, [isDeafened]);
const setPeerAudioPreference = useCallback(
(peerIdHex: string, preference: { volume?: number; muted?: boolean }) => {
const current = peerAudioPreferencesRef.current.get(peerIdHex) || {
volume: 1,
muted: false,
};
const next = { ...current, ...preference };
peerAudioPreferencesRef.current.set(peerIdHex, next);
const ctx = audioContextRef.current;
if (ctx && ctx.state === "suspended") {
ctx.resume();
}
const peer = peersRef.current.get(peerIdHex);
if (peer) {
if (peer.gainNode) {
peer.gainNode.gain.value =
isDeafenedRef.current || next.muted ? 0 : next.volume;
} else if (peer.audio) {
peer.audio.volume = Math.min(1, next.volume);
peer.audio.muted = isDeafenedRef.current || next.muted;
}
}
// For screen sharing audio, the VideoTile component handles its own volume/mute,
// so we only manage the background voice audio here.
},
[],
);
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.gainNode) {
peer.gainNode.disconnect();
}
if (peer.audio) {
peer.audio.pause();
peer.audio.srcObject = null;
}
peersRef.current.delete(peerIdHex);
setPeers(new Map(peersRef.current));
setPeerStatuses((prev) => {
const next = new Map(prev);
next.delete(peerIdHex);
return next;
});
}
},
[mediaType],
);
const createPeerConnection = useCallback(
(peerIdHex: string, initialTracks: (MediaStreamTrack | null)[] = []) => {
if (peersRef.current.has(peerIdHex))
return peersRef.current.get(peerIdHex)!.pc;
if (identity && peerIdHex === identity.toHexString()) {
console.warn(
`[WebRTC][${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 (mediaType === "voice") {
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
volume: 1,
muted: false,
};
// Use Web Audio API for voice to support > 100% volume
try {
const ctx = getAudioContext();
const stream = new MediaStream([event.track]);
// Chrome quirk: Remote WebRTC streams must be attached to an HTMLMediaElement
// to "pump" the audio, even if you're using Web Audio API for destination.
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.muted = true; // Mute the element, we'll play through Web Audio
}
existingPeer.audio.srcObject = stream;
existingPeer.audio.play().catch((err) => {
if (err.name !== "AbortError")
console.warn(
`[WebRTC][voice] Dummy audio play failed for ${peerIdHex}`,
err,
);
});
const source = ctx.createMediaStreamSource(stream);
const gainNode = ctx.createGain();
gainNode.gain.value =
isDeafenedRef.current || pref.muted ? 0 : pref.volume;
source.connect(gainNode);
gainNode.connect(ctx.destination);
existingPeer.gainNode = gainNode;
console.log(
`[WebRTC][voice] Web Audio graph connected for ${peerIdHex} (volume: ${pref.volume})`,
);
} catch (e) {
console.error(
`[WebRTC][voice] Failed to setup Web Audio for ${peerIdHex}, falling back to HTMLAudioElement`,
e,
);
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.autoplay = true;
const pref = peerAudioPreferencesRef.current.get(
peerIdHex,
) || {
volume: 1,
muted: false,
};
existingPeer.audio.volume = Math.min(1, pref.volume);
existingPeer.audio.muted =
isDeafenedRef.current || pref.muted;
}
const stream = new MediaStream([event.track]);
existingPeer.audio.srcObject = stream;
existingPeer.audio
.play()
.then(() => {
console.log(
`[WebRTC][voice] Background voice audio playing for ${peerIdHex}`,
);
})
.catch((err) => {
if (err.name !== "AbortError")
console.error(
`[WebRTC][voice] Error playing audio for ${peerIdHex}`,
err,
);
});
}
} else {
// For screen sharing, we add the audio track to the video stream object.
// VideoTile will manage the volume/mute state of the <video> element.
const currentStream =
existingPeer.videoStream || new MediaStream();
if (
!currentStream.getTracks().find((t) => t.id === event.track.id)
) {
currentStream.addTrack(event.track);
}
existingPeer.videoStream = new MediaStream(
currentStream.getTracks(),
);
console.log(
`[WebRTC][screen] Attached audio track to videoStream for ${peerIdHex}`,
);
}
} else if (event.track.kind === "video") {
const currentStream = existingPeer.videoStream || new MediaStream();
if (
!currentStream.getTracks().find((t) => t.id === event.track.id)
) {
currentStream.addTrack(event.track);
}
existingPeer.videoStream = new MediaStream(
currentStream.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));
return pc;
},
[identity, mediaType, closePeer],
);
const getPeer = useCallback(
(peerIdHex: string) => peersRef.current.get(peerIdHex),
[],
);
useEffect(() => {
if (mediaType === "voice") {
peersRef.current.forEach((peer, peerIdHex) => {
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
volume: 1,
muted: false,
};
if (peer.gainNode) {
peer.gainNode.gain.value = isDeafened || pref.muted ? 0 : pref.volume;
} else if (peer.audio) {
peer.audio.muted = isDeafened || pref.muted;
}
});
}
}, [isDeafened, mediaType]);
useEffect(() => {
if (peers.size === 0) {
if (peerStatsRef.current.size > 0) {
peerStatsRef.current = new Map();
setPeerStats(new Map());
}
return;
}
const interval = setInterval(async () => {
const newStats = new Map(peerStatsRef.current);
for (const [peerIdHex, peer] of peers.entries()) {
try {
const stats = await peer.pc.getStats();
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(),
};
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;
target.bytesReceived = report.bytesReceived || 0;
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;
}
if (prevStats) {
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);
}
}
}
}
});
newStats.set(peerIdHex, currentStats);
} catch (e) {
console.warn(
`[WebRTC][${mediaType}] Failed to get stats for ${peerIdHex}`,
e,
);
}
}
peerStatsRef.current = newStats;
setPeerStats(newStats);
}, 2000);
return () => clearInterval(interval);
}, [peers, mediaType]);
return useMemo(
() => ({
peers,
peerStatuses,
peerStats,
createPeerConnection,
closePeer,
getPeer,
setPeerAudioPreference,
peersRef,
}),
[
peers,
peerStatuses,
peerStats,
createPeerConnection,
closePeer,
getPeer,
setPeerAudioPreference,
],
);
};
@@ -1,379 +0,0 @@
import { useEffect, useCallback, useMemo, useRef } from "react";
import { Identity } from "spacetimedb";
import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react";
import { tables, reducers } from "../../../module_bindings";
import { usePeerManager } from "./usePeerManager";
export const useScreenSharingWebRTC = (
connectedChannelId: bigint | undefined,
localScreenStream: MediaStream | null,
) => {
const { identity } = useSpacetimeDB();
const [watching] = useTable(tables.watching);
const [offers] = useTable(
useMemo(
() =>
identity
? tables.screen_sdp_offer.where((o) => o.receiver.eq(identity))
: tables.screen_sdp_offer.where(() => false),
[identity],
),
);
const [answers] = useTable(
useMemo(
() =>
identity
? tables.screen_sdp_answer.where((o) => o.receiver.eq(identity))
: tables.screen_sdp_answer.where(() => false),
[identity],
),
);
const [iceCandidates] = useTable(
useMemo(
() =>
identity
? tables.screen_ice_candidate.where((o) => o.receiver.eq(identity))
: tables.screen_ice_candidate.where(() => false),
[identity],
),
);
const sendSdpOffer = useReducer(reducers.sendScreenSdpOffer);
const sendSdpAnswer = useReducer(reducers.sendScreenSdpAnswer);
const sendIceCandidate = useReducer(reducers.sendScreenIceCandidate);
// --- Refs for Coordination ---
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
const ignoreOfferRef = useRef<Map<string, boolean>>(new Map());
const signalingQueueRef = useRef<Map<string, Promise<void>>>(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]);
// --- Helper: Serialized Signaling Queue ---
const enqueueSignalingTask = useCallback(
(peerIdHex: string, task: () => Promise<void>) => {
const currentQueue =
signalingQueueRef.current.get(peerIdHex) || Promise.resolve();
const nextQueue = currentQueue.then(async () => {
try {
await task();
} catch (e) {
console.error(
`[WebRTC][screen] Signaling task failed for ${peerIdHex}`,
e,
);
}
});
signalingQueueRef.current.set(peerIdHex, nextQueue);
},
[],
);
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) => {
enqueueSignalingTask(peerIdHex, async () => {
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,
});
} finally {
makingOfferRef.current.set(peerIdHex, false);
}
});
},
[sendSdpOffer, enqueueSignalingTask],
);
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 || null,
"screen",
false,
onNegotiationNeeded,
onIceCandidate,
);
// --- Signaling Processors ---
useEffect(() => {
if (!connectedChannelId || !identity) return;
// 1. Handle Offers
const myOffers = offers.filter(
(o) =>
o.receiver.isEqual(identity) &&
!o.sender.isEqual(identity) &&
o.channelId === connectedChannelId,
);
for (const offerRow of myOffers) {
if (processedOffersRef.current.has(offerRow.id)) continue;
processedOffersRef.current.add(offerRow.id);
const peerIdHex = offerRow.sender.toHexString();
enqueueSignalingTask(peerIdHex, async () => {
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) return;
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 glare from ${peerIdHex} (impolite)`,
);
return;
}
if (offerCollision) {
console.log(
`[WebRTC][screen] Rolling back for offer glare from ${peerIdHex} (polite)`,
);
await pc.setLocalDescription({ type: "rollback" as RTCSdpType });
}
console.log(`[WebRTC][screen] Processing offer from ${peerIdHex}`);
await pc.setRemoteDescription(
new RTCSessionDescription(JSON.parse(offerRow.sdp)),
);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendSdpAnswer({
receiver: offerRow.sender,
sdp: JSON.stringify(answer),
channelId: connectedChannelId,
});
await drainCandidateQueue(peerIdHex, pc);
} catch (e) {
console.error(
`[WebRTC][screen] Failed to handle offer from ${peerIdHex}`,
e,
);
}
});
}
// 2. Handle Answers
const myAnswers = answers.filter(
(a) =>
a.receiver.isEqual(identity) &&
!a.sender.isEqual(identity) &&
a.channelId === connectedChannelId,
);
for (const answerRow of myAnswers) {
if (processedAnswersRef.current.has(answerRow.id)) continue;
processedAnswersRef.current.add(answerRow.id);
const peerIdHex = answerRow.sender.toHexString();
enqueueSignalingTask(peerIdHex, async () => {
const peer = peerManager.getPeer(peerIdHex);
if (!peer) return;
try {
console.log(`[WebRTC][screen] Processing answer from ${peerIdHex}`);
await peer.pc.setRemoteDescription(
new RTCSessionDescription(JSON.parse(answerRow.sdp)),
);
await drainCandidateQueue(peerIdHex, peer.pc);
} catch (e) {
console.error(
`[WebRTC][screen] Failed to handle answer from ${peerIdHex}`,
e,
);
}
});
}
// 3. Handle ICE Candidates
const myCandidates = iceCandidates.filter(
(c) =>
c.receiver.isEqual(identity) &&
!c.sender.isEqual(identity) &&
c.channelId === connectedChannelId,
);
for (const candRow of myCandidates) {
if (processedCandidatesRef.current.has(candRow.id)) continue;
processedCandidatesRef.current.add(candRow.id);
const peerIdHex = candRow.sender.toHexString();
enqueueSignalingTask(peerIdHex, async () => {
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) return;
try {
const candidate = JSON.parse(candRow.candidate);
const ignoreOffer = ignoreOfferRef.current.get(peerIdHex) || false;
if (pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else if (!ignoreOffer) {
const queue = candidateQueueRef.current.get(peerIdHex) || [];
queue.push(candidate);
candidateQueueRef.current.set(peerIdHex, queue);
}
} catch (e) {
console.error(
`[WebRTC][screen] Failed to handle ICE from ${peerIdHex}`,
e,
);
}
});
}
}, [
offers,
answers,
iceCandidates,
connectedChannelId,
identity,
peerManager,
sendSdpAnswer,
enqueueSignalingTask,
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 Mesh Management ---
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 screen state`);
peerManager.peersRef.current.forEach((_, id) =>
peerManager.closePeer(id),
);
processedOffersRef.current.clear();
processedAnswersRef.current.clear();
processedCandidatesRef.current.clear();
makingOfferRef.current.clear();
ignoreOfferRef.current.clear();
signalingQueueRef.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);
makingOfferRef.current.delete(id);
ignoreOfferRef.current.delete(id);
signalingQueueRef.current.delete(id);
}
});
}, [
screenPeersToConnect,
connectedChannelId,
identity,
peerManager,
localScreenStream,
]);
return {
peers: peerManager.peers,
};
};
-119
View File
@@ -1,119 +0,0 @@
import { useCallback, useEffect, useMemo } from "react";
import { Identity } from "spacetimedb";
import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react";
import { tables, reducers } from "../../../module_bindings";
import { useLocalMedia } from "./useLocalMedia";
import { useChannelAudioWebRTC } from "./useChannelAudioWebRTC";
import { useScreenSharingWebRTC } from "./useScreenSharingWebRTC";
export const useWebRTC = (connectedChannelId: bigint | undefined) => {
const { identity } = useSpacetimeDB();
const [watching] = useTable(tables.watching);
const startWatchingReducer = useReducer(reducers.startWatching);
const stopWatchingReducer = useReducer(reducers.stopWatching);
const setMuteReducer = useReducer(reducers.setMute);
const setDeafenReducer = useReducer(reducers.setDeafen);
const {
localStream,
localScreenStream,
isMuted,
isDeafened,
isSharingScreen,
toggleMute: toggleLocalMute,
toggleDeafen: toggleLocalDeafen,
startScreenShare: startLocalScreenShare,
stopScreenShare: stopLocalScreenShare,
requestMic,
releaseMic,
} = useLocalMedia(connectedChannelId);
// Sync mute/deafen to DB
useEffect(() => {
if (connectedChannelId) {
setMuteReducer({ muted: isMuted });
}
}, [isMuted, connectedChannelId, setMuteReducer]);
useEffect(() => {
if (connectedChannelId) {
setDeafenReducer({ deafened: isDeafened });
}
}, [isDeafened, connectedChannelId, setDeafenReducer]);
// --- Specialized Hooks ---
const voice = useChannelAudioWebRTC(
connectedChannelId,
identity || null,
localStream,
isDeafened,
);
const screen = useScreenSharingWebRTC(
connectedChannelId,
localScreenStream,
);
// --- Orchestration ---
useEffect(() => {
if (connectedChannelId && identity) {
console.log(
`[WebRTC] Joined channel ${connectedChannelId}, requesting mic...`,
);
requestMic();
} else {
console.log("[WebRTC] Left channel, releasing mic.");
releaseMic();
}
}, [connectedChannelId, identity, requestMic, releaseMic]);
// --- Actions ---
const startScreenShare = useCallback(() => {
startLocalScreenShare(() => {});
}, [startLocalScreenShare]);
const stopScreenShare = useCallback(() => {
stopLocalScreenShare(() => {});
}, [stopLocalScreenShare]);
const startWatching = useCallback(
(peerIdentity: Identity) => {
if (connectedChannelId) {
startWatchingReducer({
watchee: peerIdentity,
channelId: connectedChannelId,
});
}
},
[connectedChannelId, startWatchingReducer],
);
const stopWatching = useCallback(
(peerIdentity: Identity) => {
stopWatchingReducer({ watchee: peerIdentity });
},
[stopWatchingReducer],
);
return {
localStream,
localScreenStream,
peerStatuses: voice.peerStatuses,
peers: screen.peers, // For VideoGrid to show streams
startScreenShare,
stopScreenShare,
isSharingScreen,
startWatching,
stopWatching,
watching,
isMuted,
isDeafened,
toggleMute: toggleLocalMute,
toggleDeafen: toggleLocalDeafen,
peerStats: voice.peerStats,
setPeerAudioPreference: voice.setPeerAudioPreference,
};
};
export default useWebRTC;
+117
View File
@@ -0,0 +1,117 @@
import { Identity } from "spacetimedb";
import { tables, reducers } from "../../../module_bindings";
import { useTable, useReducer } from "spacetimedb/svelte";
import { LocalMediaService } from "./local-media.svelte";
import { ChannelAudioWebRTCService } from "./channel-audio-webrtc.svelte";
import { ScreenSharingWebRTCService } from "./screen-sharing-webrtc.svelte";
import * as Types from "../../../module_bindings/types";
export class WebRTCService {
identity = $state<Identity | null>(null);
connectedChannelId = $state<bigint | undefined>();
watching = $state<readonly Types.Watching[]>([]);
#startWatchingReducer = useReducer(reducers.startWatching);
#stopWatchingReducer = useReducer(reducers.stopWatching);
#setMuteReducer = useReducer(reducers.setMute);
#setDeafenReducer = useReducer(reducers.setDeafen);
localMedia: LocalMediaService;
voice: ChannelAudioWebRTCService;
screen: ScreenSharingWebRTCService;
constructor(identity: Identity | null, connectedChannelId: bigint | undefined) {
this.identity = identity;
this.connectedChannelId = connectedChannelId;
const [wStore] = useTable(tables.watching);
wStore.subscribe(v => this.watching = v);
this.localMedia = new LocalMediaService(connectedChannelId);
// Pass localMedia's stream and deafen state to voice
this.voice = new ChannelAudioWebRTCService(
identity,
connectedChannelId,
this.localMedia.localStream,
this.localMedia.isDeafened
);
this.screen = new ScreenSharingWebRTCService(
identity,
connectedChannelId,
this.localMedia.localScreenStream
);
// Sync state to sub-services
$effect(() => {
this.localMedia.connectedChannelId = this.connectedChannelId;
this.voice.identity = this.identity;
this.voice.connectedChannelId = this.connectedChannelId;
this.voice.localStream = this.localMedia.localStream;
this.voice.isDeafened = this.localMedia.isDeafened;
this.screen.identity = this.identity;
this.screen.connectedChannelId = this.connectedChannelId;
this.screen.localScreenStream = this.localMedia.localScreenStream;
});
// Sync mute/deafen to DB
$effect(() => {
if (this.connectedChannelId) {
this.#setMuteReducer({ muted: this.localMedia.isMuted });
}
});
$effect(() => {
if (this.connectedChannelId) {
this.#setDeafenReducer({ deafened: this.localMedia.isDeafened });
}
});
// Orchestration
$effect(() => {
if (this.connectedChannelId && this.identity) {
console.log(`[WebRTC] Joined channel ${this.connectedChannelId}, requesting mic...`);
this.localMedia.requestMic();
} else {
console.log("[WebRTC] Left channel, releasing mic.");
this.localMedia.releaseMic();
}
});
}
startScreenShare = () => {
this.localMedia.startScreenShare(() => {});
};
stopScreenShare = () => {
this.localMedia.stopScreenShare(() => {});
};
startWatching = (peerIdentity: Identity) => {
if (this.connectedChannelId) {
this.#startWatchingReducer({
watchee: peerIdentity,
channelId: this.connectedChannelId,
});
}
};
stopWatching = (peerIdentity: Identity) => {
this.#stopWatchingReducer({ watchee: peerIdentity });
};
// Facade getters
get localStream() { return this.localMedia.localStream; }
get localScreenStream() { return this.localMedia.localScreenStream; }
get peerStatuses() { return this.voice.peerManager.peerStatuses; }
get peers() { return this.screen.peerManager.peers; }
get isSharingScreen() { return this.localMedia.isSharingScreen; }
get isMuted() { return this.localMedia.isMuted; }
get isDeafened() { return this.localMedia.isDeafened; }
get peerStats() { return this.voice.peerManager.peerStats; }
toggleMute = () => this.localMedia.toggleMute();
toggleDeafen = () => this.localMedia.toggleDeafen();
setPeerAudioPreference = (peerIdHex: string, pref: any) => this.voice.peerManager.setPeerAudioPreference(peerIdHex, pref);
}
+47
View File
@@ -0,0 +1,47 @@
// src/config.ts
import { DbConnection } from "./module_bindings/index.ts";
import { auth } from "./auth";
export const HOST =
import.meta.env.VITE_SPACETIMEDB_HOST ?? "wss://maincloud.spacetimedb.com";
export 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: any, identity: any, token: string) => {
localStorage.setItem(TOKEN_KEY, token);
console.log(
"Connected to SpacetimeDB with identity:",
identity.toHexString(),
);
};
const onDisconnect = () => {
console.log("Disconnected from SpacetimeDB");
};
const onConnectError = (_ctx: any, err: Error) => {
console.log("Error connecting to SpacetimeDB:", err);
};
export const connectionBuilder = () => {
const builder = DbConnection.builder()
.withUri(HOST)
.withDatabaseName(DB_NAME)
.onConnect(onConnect)
.onDisconnect(onDisconnect)
.onConnectError(onConnectError);
const storedToken = localStorage.getItem(TOKEN_KEY);
if (auth.isAuthenticated && auth.user?.id_token) {
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");
return builder.withToken(storedToken);
} else {
console.log("SpacetimeDBWrapper: No token available, proceeding without.");
return builder;
}
};
+10
View File
@@ -0,0 +1,10 @@
// src/main.ts
import { mount } from "svelte";
import "./index.css";
import App from "./App.svelte";
const app = mount(App, {
target: document.getElementById("root")!,
});
export default app;
-98
View File
@@ -1,98 +0,0 @@
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
// We still need useAuth from react-oidc-context if SpacetimeDBWrapper directly uses it
// 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";
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(),
);
};
const onDisconnect = () => {
console.log("Disconnected from SpacetimeDB");
};
const onConnectError = (_ctx: ErrorContext, err: Error) => {
console.log("Error connecting to SpacetimeDB:", err);
};
// This component remains responsible for the SpacetimeDB connection logic.
// It now wraps its children with SpacetimeDBProvider.
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);
const storedToken = localStorage.getItem(TOKEN_KEY);
console.log("SpacetimeDBWrapper: localStorage TOKEN_KEY:", storedToken);
const connectionBuilder = useMemo(() => {
const builder = DbConnection.builder()
.withUri(HOST)
.withDatabaseName(DB_NAME)
.onConnect(onConnect)
.onDisconnect(onDisconnect)
.onConnectError(onConnectError);
// If we have an OIDC token, use it. Otherwise, use the stored SpacetimeDB token.
// This fallback mechanism is important for username/password login too.
if (auth.isAuthenticated && auth.user?.id_token) {
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",
);
return builder.withToken(storedToken);
} else {
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.");
return (
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
{children}
</SpacetimeDBProvider>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<OidcProvider>
<SpacetimeDBWrapper>
{/* AuthGate will decide whether to show login options or the main App */}
<AuthGate>
<App />
</AuthGate>
</SpacetimeDBWrapper>
</OidcProvider>
</StrictMode>,
);
-1
View File
@@ -1 +0,0 @@
import "@testing-library/jest-dom";
-1
View File
@@ -14,7 +14,6 @@
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
+2 -3
View File
@@ -1,11 +1,10 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import { svelte } from "@sveltejs/vite-plugin-svelte";
import basicSsl from "@vitejs/plugin-basic-ssl"; import basicSsl from "@vitejs/plugin-basic-ssl";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), basicSsl()], plugins: [svelte(), basicSsl()],
server: { server: {
https: true,
}, },
}); });
+4 -5
View File
@@ -1,13 +1,12 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react"; import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [svelte() as any],
test: { test: {
globals: true, globals: true,
environment: "jsdom", // or "node" if you're not testing DOM environment: "jsdom",
setupFiles: "./src/setupTests.ts", testTimeout: 15_000,
testTimeout: 15_000, // give extra time for real connections
hookTimeout: 15_000, hookTimeout: 15_000,
}, },
}); });