svelte migration
This commit is contained in:
@@ -794,8 +794,8 @@ index.ts → imports spacetimedb from ./schema, defines reducers
|
||||
|
||||
```
|
||||
src/module_bindings/ → Generated (spacetime generate)
|
||||
src/main.tsx → Provider, connection setup
|
||||
src/App.tsx → UI components
|
||||
src/main.ts → Provider, connection setup
|
||||
src/App.svelte → UI components
|
||||
src/config.ts → MODULE_NAME, SPACETIMEDB_URI
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>Vite + Svelte + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+7
-12
@@ -16,33 +16,28 @@
|
||||
"spacetime:publish": "spacetime publish --module-path server --server maincloud"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-oidc-context": "^3.3.1",
|
||||
"spacetimedb": "^2.1.0"
|
||||
"spacetimedb": "^2.1.0",
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/node": "^25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
||||
"@typescript-eslint/parser": "^8.57.2",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"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",
|
||||
"jsdom": "^26.0.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^7.1.5",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1578
-3870
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -576,7 +576,7 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 +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 |
@@ -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}
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
// src/auth/index.ts
|
||||
export { OidcProvider, oidcConfig } from "./OidcProvider";
|
||||
export { default as AuthGate } from "./AuthGate";
|
||||
export { default as UsernamePasswordAuth } from "./UsernamePasswordAuth";
|
||||
export { auth } from "./auth.svelte";
|
||||
export { default as AuthGate } from "./AuthGate.svelte";
|
||||
export { default as UsernamePasswordAuth } from "./UsernamePasswordAuth.svelte";
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
// src/chat/index.ts
|
||||
export { default as ChatContainer } from "./ChatContainer";
|
||||
export { useChat } from "./services/useChat";
|
||||
export { default as ChatContainer } from "./ChatContainer.svelte";
|
||||
export * from "./services";
|
||||
export * from "./utils";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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[],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useChat } from "./useChat";
|
||||
export { useWebRTC } from "./webrtc/useWebRTC";
|
||||
// src/chat/services/index.ts
|
||||
export { ChatService } from "./chat.svelte";
|
||||
export { WebRTCService } from "./webrtc/webrtc.svelte";
|
||||
export type { WebRTCStats } from "./webrtc/types";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
@@ -14,7 +14,6 @@
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
||||
+2
-3
@@ -1,11 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), basicSsl()],
|
||||
plugins: [svelte(), basicSsl()],
|
||||
server: {
|
||||
https: true,
|
||||
},
|
||||
});
|
||||
|
||||
+4
-5
@@ -1,13 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [svelte() as any],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom", // or "node" if you're not testing DOM
|
||||
setupFiles: "./src/setupTests.ts",
|
||||
testTimeout: 15_000, // give extra time for real connections
|
||||
environment: "jsdom",
|
||||
testTimeout: 15_000,
|
||||
hookTimeout: 15_000,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user