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