diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index d0fc1bcee3..5efe0fafc0 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -158,7 +158,34 @@ jobs: id: deploy env: BRANCH_DB_URL: ${{ steps.create-branch.outputs.db_url_with_pooler }} - run: echo preview_url=$(vercel deploy --prebuilt --token=${{ env.VERCEL_TOKEN }} --env BRANCH_DB_URL=${{ env.BRANCH_DB_URL }}) >> $GITHUB_OUTPUT + VERCEL_TOKEN: ${{ env.VERCEL_TOKEN }} + run: | + set -euo pipefail + # `vercel deploy` prints progress to stdout; a single-line `echo key=$(...)` into + # GITHUB_OUTPUT breaks when the capture is multiline, leaving preview_url empty and + # breaking `vercel alias`. Parse the deployment URL explicitly. + export CI=1 FORCE_COLOR=0 NO_COLOR=1 + deploy_output="$( + vercel deploy --prebuilt --yes \ + --token="${VERCEL_TOKEN}" \ + --env "BRANCH_DB_URL=${BRANCH_DB_URL:-}" \ + 2>&1 + )" || { + printf '%s\n' "${deploy_output}" + exit 1 + } + preview_url="$( + set +o pipefail + printf '%s\n' "${deploy_output}" \ + | grep -Eo 'https://[a-zA-Z0-9][a-zA-Z0-9.-]*\.vercel\.app' \ + | tail -n 1 + )" + if [ -z "${preview_url}" ]; then + echo '::error::Could not parse a *.vercel.app URL from vercel deploy output.' + printf '%s\n' "${deploy_output}" + exit 1 + fi + echo "preview_url=${preview_url}" >> "${GITHUB_OUTPUT}" - name: Alias preview to deterministic PR domain id: alias @@ -169,7 +196,7 @@ jobs: run: | set -euo pipefail PREVIEW_ALIAS_DOMAIN="pr-${PREVIEW_ALIAS_SUFFIX}.preview-app.hypha.earth" - npx --yes vercel@52 alias set "${PREVIEW_URL}" "${PREVIEW_ALIAS_DOMAIN}" --token="${VERCEL_TOKEN}" + vercel alias set "${PREVIEW_URL}" "${PREVIEW_ALIAS_DOMAIN}" --token="${VERCEL_TOKEN}" echo "preview_custom_url=https://${PREVIEW_ALIAS_DOMAIN}" >> "$GITHUB_OUTPUT" - name: Comment custom preview URL on PR diff --git a/.vscode/settings.json b/.vscode/settings.json index 2ed8e23beb..04ef067cc1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", + "prettier.prettierPath": "node_modules/prettier", + "prettier.requireConfig": true, "explorer.fileNesting.patterns": { "*.ts": "${capture}.ts, ${capture}.*.ts", "*.tsx": "${capture}.tsx, ${capture}.*.tsx, ${capture}.*.ts" diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index 7b35cc51ae..f29bc4ffdd 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -4,7 +4,7 @@ import { nxE2EPreset } from '@nx/playwright/preset'; import { workspaceRoot } from '@nx/devkit'; // For CI, you may want to set BASE_URL to the deployed application. -const baseURL = process.env['BASE_URL'] || 'http://127.0.0.1:3000'; +const baseURL = process.env['BASE_URL']?.trim() || 'http://127.0.0.1:3000'; /** * Read environment variables from file. diff --git a/apps/web-e2e/src/ai-chat-feature-flag.spec.ts b/apps/web-e2e/src/ai-chat-feature-flag.spec.ts index e67eb05602..2ff06f5311 100644 --- a/apps/web-e2e/src/ai-chat-feature-flag.spec.ts +++ b/apps/web-e2e/src/ai-chat-feature-flag.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { AiChatPanelPage } from './pages/ai-chat-panel.page'; +import { gotoApp } from './utils/nav-url'; /** * Feature Flag: NEXT_PUBLIC_ENABLE_AI_CHAT @@ -57,7 +58,7 @@ test.describe('AI Chat Panel — Feature Flag Enabled', () => { test('should not render AI trigger button on non-space pages', async ({ page, }) => { - await page.goto('/my-spaces'); + await gotoApp(page, '/en/my-spaces'); await page.waitForLoadState('domcontentloaded'); const aiButton = page.getByRole('button', { @@ -91,7 +92,7 @@ test.describe('AI Chat Panel — Feature Flag Disabled', () => { test('should not render AI trigger button on space page', async ({ page, }) => { - await page.goto('/en/dho/hypha'); + await gotoApp(page, '/en/dho/hypha'); await page.waitForLoadState('domcontentloaded'); const aiButton = page.getByRole('button', { @@ -101,7 +102,7 @@ test.describe('AI Chat Panel — Feature Flag Disabled', () => { }); test('should not render sidebar panel markup', async ({ page }) => { - await page.goto('/en/dho/hypha'); + await gotoApp(page, '/en/dho/hypha'); await page.waitForLoadState('domcontentloaded'); const sidebar = page.locator('[data-sidebar="sidebar"]'); @@ -109,7 +110,7 @@ test.describe('AI Chat Panel — Feature Flag Disabled', () => { }); test('should render page content normally', async ({ page }) => { - await page.goto('/en/dho/hypha'); + await gotoApp(page, '/en/dho/hypha'); await page.waitForLoadState('domcontentloaded'); // Space page content should still render diff --git a/apps/web-e2e/src/coherence-chat-panel.spec.ts b/apps/web-e2e/src/coherence-chat-panel.spec.ts index 7699f778f5..c7b8880179 100644 --- a/apps/web-e2e/src/coherence-chat-panel.spec.ts +++ b/apps/web-e2e/src/coherence-chat-panel.spec.ts @@ -30,6 +30,7 @@ import { test, expect } from '@playwright/test'; import { CoherenceChatPanelPage } from './pages/coherence-chat-panel.page'; import { HumanChatPanelPage } from './pages/human-chat-panel.page'; +import { gotoApp } from './utils/nav-url'; test.describe('Coherence Chat Panel Integration', () => { const SPACE_SLUG = 'hypha'; @@ -46,7 +47,7 @@ test.describe('Coherence Chat Panel Integration', () => { page, }) => { const chatPanel = new HumanChatPanelPage(page); - await page.goto(`/en/dho/${SPACE_SLUG}/coherence`); + await gotoApp(page, `/en/dho/${SPACE_SLUG}/coherence`); await page.waitForLoadState('domcontentloaded'); await expect(chatPanel.openButton).toBeVisible(); @@ -60,7 +61,7 @@ test.describe('Coherence Chat Panel Integration', () => { page, }) => { const chatPanel = new HumanChatPanelPage(page); - await page.goto(`/en/dho/${SPACE_SLUG}/coherence`); + await gotoApp(page, `/en/dho/${SPACE_SLUG}/coherence`); await page.waitForLoadState('domcontentloaded'); await chatPanel.openPanel(); @@ -73,7 +74,7 @@ test.describe('Coherence Chat Panel Integration', () => { page, }) => { const chatPanel = new CoherenceChatPanelPage(page, SPACE_SLUG); - await page.goto(`/en/dho/${SPACE_SLUG}/coherence`); + await gotoApp(page, `/en/dho/${SPACE_SLUG}/coherence`); await page.waitForLoadState('domcontentloaded'); // Open button exists; back button should NOT be present in space mode await chatPanel.openPanelButton.click(); @@ -85,7 +86,7 @@ test.describe('Coherence Chat Panel Integration', () => { page, }) => { const chatPanel = new HumanChatPanelPage(page); - await page.goto(`/en/dho/${SPACE_SLUG}/coherence`); + await gotoApp(page, `/en/dho/${SPACE_SLUG}/coherence`); await page.waitForLoadState('domcontentloaded'); await chatPanel.openPanel(); @@ -440,7 +441,7 @@ test.describe('Coherence Chat Panel Integration', () => { 'space chat mode is the default state of the panel on coherence page', async ({ page }) => { const chatPanel = new HumanChatPanelPage(page); - await page.goto(`/en/dho/${SPACE_SLUG}/coherence`); + await gotoApp(page, `/en/dho/${SPACE_SLUG}/coherence`); await page.waitForLoadState('domcontentloaded'); await chatPanel.openPanel(); @@ -461,7 +462,7 @@ test.describe('Coherence Chat Panel Integration', () => { await expect(chatPanel.headerText).toBeVisible(); // "Chat" // Navigate to coherence page - await page.goto(`/en/dho/${SPACE_SLUG}/coherence`); + await gotoApp(page, `/en/dho/${SPACE_SLUG}/coherence`); await page.waitForLoadState('domcontentloaded'); // Panel should still be in space chat mode (no signal was clicked) diff --git a/apps/web-e2e/src/human-chat-panel-feature-flag.spec.ts b/apps/web-e2e/src/human-chat-panel-feature-flag.spec.ts index 7e77787475..fde9ddb2e2 100644 --- a/apps/web-e2e/src/human-chat-panel-feature-flag.spec.ts +++ b/apps/web-e2e/src/human-chat-panel-feature-flag.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { HumanChatPanelPage } from './pages/human-chat-panel.page'; +import { gotoApp } from './utils/nav-url'; /** * Default runtime: off. The enabled describe block sets `HYPHA_ENABLE_HUMAN_CHAT=true`. @@ -62,7 +63,7 @@ test.describe('Human Chat Panel — kill switch (disabled)', () => { test('should not render Human Chat trigger button on space page', async ({ page, }) => { - await page.goto('/en/dho/hypha/agreements'); + await gotoApp(page, '/en/dho/hypha/agreements'); await page.waitForLoadState('domcontentloaded'); const chatButton = page.getByRole('button', { @@ -72,7 +73,7 @@ test.describe('Human Chat Panel — kill switch (disabled)', () => { }); test('should render page content normally', async ({ page }) => { - await page.goto('/en/dho/hypha/agreements'); + await gotoApp(page, '/en/dho/hypha/agreements'); await page.waitForLoadState('domcontentloaded'); await expect(page.getByText('Agreements')).toBeVisible(); diff --git a/apps/web-e2e/src/menu-top-consistent-height.spec.ts b/apps/web-e2e/src/menu-top-consistent-height.spec.ts index f87d1a0eef..343d3e0df2 100644 --- a/apps/web-e2e/src/menu-top-consistent-height.spec.ts +++ b/apps/web-e2e/src/menu-top-consistent-height.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { LayoutPage } from './pages/layout.page'; +import { gotoApp } from './utils/nav-url'; /** * MenuTop — Consistent Height @@ -35,7 +36,7 @@ test.describe('MenuTop consistent height', () => { const menuSelector = 'header.sticky'; // Measure on a space page (trigger icons present) - await page.goto('/en/dho/hypha/agreements'); + await gotoApp(page, '/en/dho/hypha/agreements'); await page.waitForLoadState('domcontentloaded'); const spaceHeader = page.locator(menuSelector).first(); await spaceHeader.waitFor({ state: 'visible' }); @@ -43,7 +44,7 @@ test.describe('MenuTop consistent height', () => { expect(spaceBox).not.toBeNull(); // Measure on a non-space page (no trigger icons) - await page.goto('/en/network'); + await gotoApp(page, '/en/network'); await page.waitForLoadState('domcontentloaded'); const networkHeader = page.locator(menuSelector).first(); await networkHeader.waitFor({ state: 'visible' }); @@ -57,7 +58,7 @@ test.describe('MenuTop consistent height', () => { test('--menu-top-height CSS variable should be set and integer', async ({ page, }) => { - await page.goto('/en/dho/hypha/agreements'); + await gotoApp(page, '/en/dho/hypha/agreements'); await page.waitForLoadState('domcontentloaded'); // Poll until --menu-top-height is set by ResizeObserver diff --git a/apps/web-e2e/src/pages/ai-chat-panel.page.ts b/apps/web-e2e/src/pages/ai-chat-panel.page.ts index 9ebdcae6c4..77acf18582 100644 --- a/apps/web-e2e/src/pages/ai-chat-panel.page.ts +++ b/apps/web-e2e/src/pages/ai-chat-panel.page.ts @@ -68,7 +68,7 @@ export class AiChatPanelPage extends BasePage { } async open() { - await this.page.goto('/en/dho/hypha'); + await this.gotoApp('/en/dho/hypha'); await this.waitForPageLoad(); } diff --git a/apps/web-e2e/src/pages/base.page.ts b/apps/web-e2e/src/pages/base.page.ts index d55e1edd65..5a0ba7896f 100644 --- a/apps/web-e2e/src/pages/base.page.ts +++ b/apps/web-e2e/src/pages/base.page.ts @@ -1,4 +1,5 @@ import { Page } from '@playwright/test'; +import { resolveAppUrl } from '../utils/nav-url'; export class BasePage { readonly page: Page; @@ -7,6 +8,11 @@ export class BasePage { this.page = page; } + /** Same origin as Playwright `use.baseURL`; safe when config is not loaded. */ + async gotoApp(path: string) { + await this.page.goto(resolveAppUrl(path)); + } + async waitForPageLoad() { await this.page.waitForLoadState('domcontentloaded'); } diff --git a/apps/web-e2e/src/pages/coherence-chat-panel.page.ts b/apps/web-e2e/src/pages/coherence-chat-panel.page.ts index 6f562e6fb8..ab2f5db727 100644 --- a/apps/web-e2e/src/pages/coherence-chat-panel.page.ts +++ b/apps/web-e2e/src/pages/coherence-chat-panel.page.ts @@ -149,7 +149,7 @@ export class CoherenceChatPanelPage extends BasePage { * Navigate directly to the coherence page. */ async openCoherencePage() { - await this.page.goto(`/en/dho/${this.spaceSlug}/coherence`); + await this.gotoApp(`/en/dho/${this.spaceSlug}/coherence`); await this.waitForPageLoad(); } diff --git a/apps/web-e2e/src/pages/coherence.page.ts b/apps/web-e2e/src/pages/coherence.page.ts index 506385bccf..bc9e31f052 100644 --- a/apps/web-e2e/src/pages/coherence.page.ts +++ b/apps/web-e2e/src/pages/coherence.page.ts @@ -81,7 +81,7 @@ export class CoherencePage extends BasePage { * from which we can click the Coherence navigation tab. */ async openDhoPage() { - await this.page.goto(`/en/dho/${this.spaceSlug}/agreements`); + await this.gotoApp(`/en/dho/${this.spaceSlug}/agreements`); await this.waitForPageLoad(); } @@ -89,7 +89,7 @@ export class CoherencePage extends BasePage { * Navigate directly to the coherence page URL. */ async openCoherencePage() { - await this.page.goto(`/en/dho/${this.spaceSlug}/coherence`); + await this.gotoApp(`/en/dho/${this.spaceSlug}/coherence`); await this.waitForPageLoad(); } diff --git a/apps/web-e2e/src/pages/human-chat-panel.page.ts b/apps/web-e2e/src/pages/human-chat-panel.page.ts index 2f17c2a200..7342e1cdee 100644 --- a/apps/web-e2e/src/pages/human-chat-panel.page.ts +++ b/apps/web-e2e/src/pages/human-chat-panel.page.ts @@ -77,7 +77,7 @@ export class HumanChatPanelPage extends BasePage { /** Navigate to a space's agreements page. Defaults to 'hypha'. */ async navigateToSpace(spaceSlug = 'hypha') { - await this.page.goto(`/en/dho/${spaceSlug}/agreements`); + await this.gotoApp(`/en/dho/${spaceSlug}/agreements`); await this.waitForPageLoad(); } diff --git a/apps/web-e2e/src/pages/layout.page.ts b/apps/web-e2e/src/pages/layout.page.ts index a6a68b0d2c..53b919ff75 100644 --- a/apps/web-e2e/src/pages/layout.page.ts +++ b/apps/web-e2e/src/pages/layout.page.ts @@ -53,7 +53,7 @@ export class LayoutPage extends BasePage { } async open(path = '/en/dho/hypha/agreements') { - await this.page.goto(path); + await this.gotoApp(path); await this.waitForPageLoad(); } diff --git a/apps/web-e2e/src/pages/my-spaces.page.ts b/apps/web-e2e/src/pages/my-spaces.page.ts index 308889679c..fe0cde022d 100644 --- a/apps/web-e2e/src/pages/my-spaces.page.ts +++ b/apps/web-e2e/src/pages/my-spaces.page.ts @@ -14,7 +14,7 @@ export class MySpaces extends BasePage { } async open() { - await this.page.goto('/my-spaces'); + await this.gotoApp('/my-spaces'); await this.waitForPageLoad(); } diff --git a/apps/web-e2e/src/panels-space-context.spec.ts b/apps/web-e2e/src/panels-space-context.spec.ts index 6b56500e38..b4735ebce6 100644 --- a/apps/web-e2e/src/panels-space-context.spec.ts +++ b/apps/web-e2e/src/panels-space-context.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { gotoApp } from './utils/nav-url'; /** * Side Panels — Space Context Only @@ -34,7 +35,7 @@ test.describe('Panels visible on space pages', () => { }); test('should show Human Chat trigger on a space page', async ({ page }) => { - await page.goto('/en/dho/hypha/agreements'); + await gotoApp(page, '/en/dho/hypha/agreements'); await page.waitForLoadState('domcontentloaded'); await expect( @@ -43,7 +44,7 @@ test.describe('Panels visible on space pages', () => { }); test('should show AI trigger on a space page', async ({ page }) => { - await page.goto('/en/dho/hypha/agreements'); + await gotoApp(page, '/en/dho/hypha/agreements'); await page.waitForLoadState('domcontentloaded'); await expect(page.getByRole('button', { name: AI_TRIGGER })).toBeVisible(); @@ -69,7 +70,7 @@ test.describe('Panels hidden on non-space pages', () => { }); test('should NOT show Human Chat trigger on /network', async ({ page }) => { - await page.goto('/en/network'); + await gotoApp(page, '/en/network'); await page.waitForLoadState('domcontentloaded'); await expect(page.getByRole('button', { name: CHAT_TRIGGER })).toHaveCount( @@ -78,14 +79,14 @@ test.describe('Panels hidden on non-space pages', () => { }); test('should NOT show AI trigger on /network', async ({ page }) => { - await page.goto('/en/network'); + await gotoApp(page, '/en/network'); await page.waitForLoadState('domcontentloaded'); await expect(page.getByRole('button', { name: AI_TRIGGER })).toHaveCount(0); }); test('should NOT show Human Chat trigger on /my-spaces', async ({ page }) => { - await page.goto('/en/my-spaces'); + await gotoApp(page, '/en/my-spaces'); await page.waitForLoadState('domcontentloaded'); await expect(page.getByRole('button', { name: CHAT_TRIGGER })).toHaveCount( @@ -94,14 +95,14 @@ test.describe('Panels hidden on non-space pages', () => { }); test('should NOT show AI trigger on /my-spaces', async ({ page }) => { - await page.goto('/en/my-spaces'); + await gotoApp(page, '/en/my-spaces'); await page.waitForLoadState('domcontentloaded'); await expect(page.getByRole('button', { name: AI_TRIGGER })).toHaveCount(0); }); test('should NOT render sidebar markup on /network', async ({ page }) => { - await page.goto('/en/network'); + await gotoApp(page, '/en/network'); await page.waitForLoadState('domcontentloaded'); // No panel sidebars should be in the DOM on non-space pages @@ -134,7 +135,7 @@ test.describe('Panels appear after navigating into a space', () => { page, }) => { // Start on a non-space page - await page.goto('/en/network'); + await gotoApp(page, '/en/network'); await page.waitForLoadState('domcontentloaded'); // Verify triggers are absent diff --git a/apps/web-e2e/src/utils/nav-url.ts b/apps/web-e2e/src/utils/nav-url.ts new file mode 100644 index 0000000000..2e20bca1cd --- /dev/null +++ b/apps/web-e2e/src/utils/nav-url.ts @@ -0,0 +1,25 @@ +import type { Page } from '@playwright/test'; + +/** + * Default matches `playwright.config.ts` when `BASE_URL` is unset. + * Ensures `page.goto` works when Playwright runs without that config (e.g. wrong CWD), + * where `use.baseURL` is missing and relative URLs throw "Cannot navigate to invalid URL". + */ +const DEFAULT_BASE_URL = 'http://127.0.0.1:3000'; + +function resolveBaseUrl(): string { + return process.env['BASE_URL']?.trim() || DEFAULT_BASE_URL; +} + +/** + * Absolute URL for an app-relative path (must start with `/`). + */ +export function resolveAppUrl(path: string): string { + const normalized = path.startsWith('/') ? path : `/${path}`; + return new URL(normalized, resolveBaseUrl()).href; +} + +/** Navigate using the same base URL convention as the Playwright config. */ +export async function gotoApp(page: Page, path: string): Promise { + await page.goto(resolveAppUrl(path)); +} diff --git a/apps/web/src/app/[lang]/dho/[id]/_components/breadcrumbs.tsx b/apps/web/src/app/[lang]/dho/[id]/_components/breadcrumbs.tsx index fe6e6f7043..8f438e984a 100644 --- a/apps/web/src/app/[lang]/dho/[id]/_components/breadcrumbs.tsx +++ b/apps/web/src/app/[lang]/dho/[id]/_components/breadcrumbs.tsx @@ -16,7 +16,6 @@ async function RecursiveBreadcrumbItem({ depth?: number; maxDepth?: number; }) { - console.debug('RecursiveBreadcrumbItem', { spaceId, depth, maxDepth }); const space = await findParentSpaceById({ id: spaceId }, { db }); if (!space || depth > maxDepth) return null; diff --git a/apps/web/src/app/[lang]/dho/[id]/layout.tsx b/apps/web/src/app/[lang]/dho/[id]/layout.tsx index ef1002eb2b..c6a82a9ae9 100644 --- a/apps/web/src/app/[lang]/dho/[id]/layout.tsx +++ b/apps/web/src/app/[lang]/dho/[id]/layout.tsx @@ -11,7 +11,6 @@ import { } from '@hypha-platform/epics'; import '../../_shared/space-accent.css'; import { Locale } from '@hypha-platform/i18n'; -import { Container } from '@hypha-platform/ui'; import { findSpaceBySlug } from '@hypha-platform/core/server'; import { getDhoPathAgreements } from './@tab/agreements/constants'; import { ActionButtons } from './_components/action-buttons'; @@ -113,13 +112,12 @@ export default async function DhoLayout({ return ( {/* - Full-width row: `Container` already applies max-width + horizontal padding. - Dropping the extra `mx-auto max-w-container-2xl` wrapper avoided double - max-width + centering that made the main column look pushed with a void on the left. + Main column must span the full width next to side panels: `Container` max-width + `mx-auto` + centers content and leaves empty gutters — very visible when the Human chat panel narrows + the column (reads as a dead strip beside the hero / secondary chrome). Use padding only. */}
- {/* `px-4!` = 16px: tighter than default Container `px-5` (20px) for DHO hero/tabs vs app chrome */} - +
{/* React 19+: link rel="preload" is hoisted to document head */} {heroBannerImageHref !== DEFAULT_SPACE_LEAD_IMAGE ? ( - +
{aside}
diff --git a/apps/web/src/app/api/matrix/token/route.ts b/apps/web/src/app/api/matrix/token/route.ts index e0a9dbd928..40fe62ccd9 100644 --- a/apps/web/src/app/api/matrix/token/route.ts +++ b/apps/web/src/app/api/matrix/token/route.ts @@ -18,6 +18,7 @@ const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID; const PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET; const MATRIX_HOMESERVER_URL = process.env.NEXT_PUBLIC_MATRIX_HOMESERVER_URL; const ADMIN_BASE_NAME = 'hypha_admin'; +const LEGACY_SHARED_SECRET_DEVICE_ID = 'shared_secret_registration'; function validateEnvVars() { if (!PRIVY_APP_ID || !PRIVY_APP_SECRET || !MATRIX_HOMESERVER_URL) { @@ -200,7 +201,11 @@ export async function GET(request: NextRequest) { }); if (existing) { const accessToken = decryptMatrixToken(existing.encryptedAccessToken); - if (await matrixAuthClient.validateToken(accessToken)) { + const hasValidToken = await matrixAuthClient.validateToken(accessToken); + if ( + hasValidToken && + existing.deviceId !== LEGACY_SHARED_SECRET_DEVICE_ID + ) { return NextResponse.json({ accessToken, userId: existing.matrixUserId, @@ -256,7 +261,11 @@ export async function GET(request: NextRequest) { }); } - throw new Error('Matrix user link exists but cannot be updated'); + throw new Error( + hasValidToken + ? 'Matrix user link has legacy device id but cannot be rotated' + : 'Matrix user link exists but cannot be updated', + ); } } } diff --git a/docs/requirements/matrix-voip-turn-server-setup.md b/docs/requirements/matrix-voip-turn-server-setup.md new file mode 100644 index 0000000000..d2f928af1d --- /dev/null +++ b/docs/requirements/matrix-voip-turn-server-setup.md @@ -0,0 +1,123 @@ +# Matrix homeserver VoIP / TURN — operator setup + +**Audience:** whoever runs Hypha’s Matrix homeserver (staging + production). +**Clients:** Hypha Web uses **`matrix-js-sdk`** and expects the standard Matrix **VoIP REST API** and ICE servers — see [voice-video-call-phase-0-runbook.md](./voice-video-call-phase-0-runbook.md) §0.1. + +This document complements the **frontend** knobs in `packages/core/src/matrix/client/matrix-webrtc-env.ts` (`NEXT_PUBLIC_MATRIX_WEBRTC_*`). Those only tune **browser** behavior after the homeserver returns valid ICE servers; they **cannot** replace TURN **infrastructure**. + +--- + +## Roles at a glance + +| Perspective | Responsibility here | +|-------------|----------------------| +| **Matrix / SDK** | `MatrixClient.checkTurnServers()` → **`GET /_matrix/client/v3/voip/turnServer`** (authenticated). Returned `uris` become `RTCPeerConnection` `iceServers`. Missing or empty `uris` ⇒ most **NAT** deployments get **no reliable media**. | +| **Full-stack / Next** | App must be **HTTPS** (or localhost) for **`getUserMedia`**. If you add a strict **CSP**, extend **`connect-src`** for your TURN/STUN hostnames (see Phase 0 runbook §0.3). Env `NEXT_PUBLIC_MATRIX_WEBRTC_*` maps to `createClient()` (e.g. `forceTURN`, fallback STUN). | +| **UX** | Users see “connecting forever” / stall banners when **ICE fails** — indistinguishable from bugs without server-side verification. Clear copy should mention network/TURN when support knows HS is misconfigured. | +| **QA** | Verify **`/voip/turnServer`** returns **`uris`** for a real user token; run **two browsers / two accounts** on **different networks** or force relay (`NEXT_PUBLIC_MATRIX_WEBRTC_FORCE_TURN=true` in preview) to prove TURN path. | + +--- + +## 1. What must be true on the server + +1. **Homeserver exposes VoIP credentials API** + Clients call (Matrix spec): + **`GET https:///_matrix/client/v3/voip/turnServer`** + With a valid **`Authorization: Bearer `**. + + Successful response shape (conceptually): **`uris`**, **`username`**, **`password`**, **`ttl`** (synapse fills these from its TURN integration). + +2. **A working TURN server** (typically **coturn**) reachable from browsers on the **public internet**, with UDP (and often TCP/TLS for restrictive networks). + +3. **Synapse ↔ coturn authentication** aligned (usually **shared secret** (`use-auth-secret` on coturn, `turn_shared_secret` in Synapse)), so Synapse can mint **temporary** TURN passwords. + +Exact YAML keys evolve with Synapse releases — always verify against **[Synapse VoIP / TURN documentation](https://matrix-org.github.io/synapse/latest/turn-howto.html)** for your installed version. + +--- + +## 2. Recommended deployment shape + +### 2.1 Coturn (TURN/STUN relay) + +- Run **coturn** on a host with a **stable DNS name** and **public IP**. +- Open firewall/security groups for: + - **UDP** on your TURN ports (default often **3478**, plus relay port range you configure). + - **TCP** **3478** (and **5349** if using TLS TURN). + - **Relay port range** (e.g. **49152–65535** UDP) as documented in coturn config — this is where media often flows. +- Configure **`realm`**, **`listening-ip`**, **`external-ip`** (if behind NAT), and **`use-auth-secret`** + **`static-auth-secret`** matching the homeserver TURN shared secret. + +### 2.2 Synapse + +In **`homeserver.yaml`** (names may vary slightly by version): + +- **`turn_uris`**: list of `turn:` / `turns:` URIs pointing at your coturn (must match what browsers can resolve and reach). +- **`turn_shared_secret`**: same secret as coturn `static-auth-secret` when using short-lived credentials. +- Optionally tune **`turn_user_lifetime`** (seconds). + +After editing, **restart Synapse**. + +### 2.3 Dendrite + +In **`dendrite.yaml`**, configure TURN under **`client_api.turn`**: + +```yaml +client_api: + turn: + turn_user_lifetime: "24h" + turn_uris: + - "turn:turn.example.org:3478?transport=udp" + - "turn:turn.example.org:3478?transport=tcp" + turn_shared_secret: "" + turn_username: "" + turn_password: "" +``` + +After editing, **restart Dendrite**. + +### 2.4 Permissions + +Ensure Matrix users who should call are **allowed** to request TURN credentials (Synapse historically tied this to settings like allowing VoIP; check current Synapse docs for **guest / restricted** accounts). + +--- + +## 3. Verification (before blaming the web app) + +### 3.1 API returns ICE servers + +With a **real user access token** (same HS Hypha uses in `NEXT_PUBLIC_MATRIX_HOMESERVER_URL`): + +```bash +curl -sS -H "Authorization: Bearer " \ + "https:///_matrix/client/v3/voip/turnServer" | jq . +``` + +**Pass:** JSON includes non-empty **`uris`** and credential fields suitable for WebRTC. + +**Fail:** empty **`uris`**, **403**, or errors ⇒ fix Synapse/coturn **before** frontend debugging. + +### 3.2 Browser / SDK path + +After login in Hypha (devtools console, filter **`hypha.group_call`** if telemetry is enabled): + +- **`turnCredsOk`** / **`iceHasTurn`** in **`turn_probe`** events (when implemented in your build) — or inspect **`matrix-js-sdk`** logs for **“failed to get TURN credentials”**. + +### 3.3 Two-party QA + +1. Two **different** users, **same room**, start/join call. +2. Prefer **two networks** (e.g. LTE + home Wi‑Fi) or set **`NEXT_PUBLIC_MATRIX_WEBRTC_FORCE_TURN=true`** on a preview build to **force relay** and prove TURN. + +--- + +## 4. Hypha Web client (reference only) + +- **`MatrixProvider`** → `createClient({ disableVoip: false, … })` — VoIP **enabled**. +- **`NEXT_PUBLIC_MATRIX_WEBRTC_FORCE_TURN`**, **`NEXT_PUBLIC_MATRIX_WEBRTC_FALLBACK_ICE_ALLOWED`**, **`NEXT_PUBLIC_MATRIX_WEBRTC_ICE_POOL_SIZE`** — optional **browser** tuning; **not** a substitute for server TURN. + +--- + +## 5. References + +- [Synapse TURN how-to](https://matrix-org.github.io/synapse/latest/turn-howto.html) +- [Matrix Client-Server API — `/voip/turnServer`](https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3voipturnserver) +- [voice-video-call-phase-0-runbook.md](./voice-video-call-phase-0-runbook.md) — checklist table +- [voice-video-call-matrix-tech-spec.md](./voice-video-call-matrix-tech-spec.md) — SDK options overview diff --git a/docs/requirements/voice-video-call-implementation-spec.md b/docs/requirements/voice-video-call-implementation-spec.md index 05adea3438..1779f0331b 100644 --- a/docs/requirements/voice-video-call-implementation-spec.md +++ b/docs/requirements/voice-video-call-implementation-spec.md @@ -100,7 +100,7 @@ Extend `MatrixSdk.createClient({ ... })` with **explicit** VoIP-related options | Option | Value (v1 recommendation) | Rationale | | -------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------ | | `disableVoip` | `false` | Enable TURN fetch and VoIP stack. | -| `useE2eForGroupCall` | `true` (default in typings) | Encrypt to-device signaling for group calls where supported. | +| `useE2eForGroupCall` | `false` for `matrix-js-sdk@40.x` | SDK v40 Rust crypto path throws `Unimplemented` for encrypted group-call to-device VoIP signaling; WebRTC media remains encrypted. | | `useLivekitForGroupCalls` | `false` | Native WebRTC via SDK for v1; LiveKit is a later epic. | | `forceTURN` | `false` initially; expose env/feature flag if enterprise NAT issues | Stricter relay; test in staging. | | `fallbackICEServerAllowed` | align with security review | Only enable if HS provides no TURN and product approves. | diff --git a/docs/requirements/voice-video-call-matrix-tech-spec.md b/docs/requirements/voice-video-call-matrix-tech-spec.md index 9c8cad08c1..09ec29e6a3 100644 --- a/docs/requirements/voice-video-call-matrix-tech-spec.md +++ b/docs/requirements/voice-video-call-matrix-tech-spec.md @@ -38,7 +38,7 @@ Relevant options (see [ICreateClientOpts](https://matrix-org.github.io/matrix-js | **`forceTURN`** | Force relay via TURN (stricter NAT/firewall behavior). | | **`fallbackICEServerAllowed`** | Allow SDK fallback ICE if the homeserver offers none. | | **`iceCandidatePoolSize`** | Pre-gather candidates for faster setup (privacy/battery tradeoff). | -| **`useE2eForGroupCall`** | Encrypt **to-device** signaling for group calls (default **true** in typings). | +| **`useE2eForGroupCall`** | Encrypt **to-device** signaling for group calls where supported. Keep **false** on `matrix-js-sdk@40.x`; its Rust crypto path throws `Unimplemented` for encrypted group-call VoIP signaling. | | **`useLivekitForGroupCalls`** | If **true**, the SDK **does not** establish WebRTC media for group calls; it creates **signaling** only so the app can attach **LiveKit** (or similar) for media. | | **`livekitServiceURL`** | Service URL used when integrating LiveKit-style flows. | | **`isVoipWithNoMediaAllowed`** | Allow joining group call **without** local A/V (interpretation per SDK docs). | diff --git a/docs/requirements/voice-video-call-phase-0-runbook.md b/docs/requirements/voice-video-call-phase-0-runbook.md index 8f589799ec..3a06da5688 100644 --- a/docs/requirements/voice-video-call-phase-0-runbook.md +++ b/docs/requirements/voice-video-call-phase-0-runbook.md @@ -8,6 +8,8 @@ This document satisfies **Phase 0** of [voice-video-call-implementation-plan.md] **Owner:** whoever operates the Matrix homeserver used by Hypha (staging + production). +**Full operator steps (Synapse + coturn, curl checks, QA):** [matrix-voip-turn-server-setup.md](./matrix-voip-turn-server-setup.md). + Use the table below and record **server name**, **date**, and **result** in your deployment notes or ticket. | Check | How | Pass criteria | diff --git a/package.json b/package.json index 0f31fa0f8c..e3f36bea5c 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "lint": "pnpm run check:matrix-sdk && turbo run lint", "verify:messages": "pnpm --filter @hypha-platform/i18n run verify:messages", "lint:fix": "turbo run lint -- --fix", - "format:check": "npx --yes prettier@2.8.8 --check 'apps/**/*.{ts,tsx}' --check 'packages/**/*.{ts,tsx}'", - "format:fix": "npx --yes prettier@2.8.8 --write 'apps/**/*.{ts,tsx}' --write 'packages/**/*.{ts,tsx}'", + "format:check": "prettier --check \"apps/**/*.{ts,tsx}\" \"packages/**/*.{ts,tsx}\"", + "format:fix": "prettier --write \"apps/**/*.{ts,tsx}\" \"packages/**/*.{ts,tsx}\"", "env:copy": "bash scripts/copy-env.sh", "vercel:link": "npx vercel link", "e2e": "npx playwright test --config=apps/web-e2e/playwright-local.config.ts --reporter=line", @@ -257,5 +257,13 @@ "vite-plugin-svgr": "^4.3.0", "vitest": "^1.3.1", "zx": "^8.3.2" + }, + "pnpm": { + "overrides": { + "@hono/node-server": ">=1.19.13 <2", + "@xmldom/xmldom": ">=0.9.9 <1", + "dompurify": ">=3.4.0 <4", + "hono": ">=4.12.12 <5" + } } } diff --git a/packages/core/src/coherence/lib/matrix-shared-secret.ts b/packages/core/src/coherence/lib/matrix-shared-secret.ts index 4806f91937..20de0fe86c 100644 --- a/packages/core/src/coherence/lib/matrix-shared-secret.ts +++ b/packages/core/src/coherence/lib/matrix-shared-secret.ts @@ -15,6 +15,10 @@ type RegisterResponse = { const DEFAULT_TIMEOUT_MS = 30_000; +function createMatrixDeviceId(): string { + return `hypha_${crypto.randomUUID().replace(/-/g, '')}`; +} + async function fetchWithTimeout( url: string, options: RequestInit = {}, @@ -164,6 +168,8 @@ export class MatrixSharedSecret { username, password, admin: isAdmin, + device_id: createMatrixDeviceId(), + initial_device_display_name: 'Hypha Web', mac, }), } as const; @@ -338,11 +344,12 @@ export class MatrixSharedSecret { 'Content-Type': 'application/json', }, body: JSON.stringify({ + device_id: createMatrixDeviceId(), identifier: { type: 'm.id.user', user: username, }, - initial_device_display_name: `device_${Date.now()}`, + initial_device_display_name: 'Hypha Web', password, type: 'm.login.password', }), diff --git a/packages/core/src/matrix/client/hooks/group-call-webrtc-diagnostics.ts b/packages/core/src/matrix/client/hooks/group-call-webrtc-diagnostics.ts new file mode 100644 index 0000000000..518f052de5 --- /dev/null +++ b/packages/core/src/matrix/client/hooks/group-call-webrtc-diagnostics.ts @@ -0,0 +1,252 @@ +'use client'; + +import { + GroupCallStatsReportEvent, + type GroupCall, + type MatrixClient, +} from 'matrix-js-sdk'; +import { logSpaceGroupCallEvent } from './space-group-call-telemetry'; + +/** Fields we log from Matrix SDK summary stats (avoid deep imports Next cannot bundle). */ +type GroupCallSummaryStatsReport = { + percentageReceivedMedia: number; + percentageReceivedAudioMedia: number; + percentageReceivedVideoMedia: number; + maxJitter: number; + maxPacketLoss: number; + peerConnections: number; + opponentUsersInCall?: number; + opponentDevicesInCall?: number; + diffDevicesToPeerConnections?: number; + ratioPeerConnectionToDevices?: number; +}; + +const TURN_PROBE_LOG_THROTTLE_MS = 60_000; + +type IceUrlKind = 'stun' | 'turn' | 'turns' | 'unknown'; + +function iceUrlKind(url: string): IceUrlKind { + const u = url.trim().toLowerCase(); + if (u.startsWith('stun:') || u.startsWith('stuns:')) return 'stun'; + if (u.startsWith('turns:')) return 'turns'; + if (u.startsWith('turn:')) return 'turn'; + return 'unknown'; +} + +/** One object per RTCPeerConnection `iceServers` entry — no secrets. */ +export function summarizeMatrixIceServers(raw: RTCIceServer[] | undefined): { + /** Count of `iceServers` objects. */ + entryCount: number; + /** Total URL strings across all entries. */ + urlCount: number; + hasStun: boolean; + hasTurn: boolean; + hasTurns: boolean; + /** Sample of hostname-like segments (first label of host), not full URLs. */ + hostHints: string[]; +} { + if (!raw?.length) { + return { + entryCount: 0, + urlCount: 0, + hasStun: false, + hasTurn: false, + hasTurns: false, + hostHints: [], + }; + } + let urlCount = 0; + let hasStun = false; + let hasTurn = false; + let hasTurns = false; + const hostHints: string[] = []; + + for (const e of raw) { + const urls = Array.isArray(e.urls) ? e.urls : e.urls ? [e.urls] : []; + urlCount += urls.length; + for (const url of urls) { + const k = iceUrlKind(String(url)); + if (k === 'stun') hasStun = true; + if (k === 'turn') hasTurn = true; + if (k === 'turns') hasTurns = true; + try { + const u = new URL( + String(url) + .replace(/^stun[s]?:/i, 'https:') + .replace(/^turn[s]?:/i, 'https:'), + ); + const h = u.hostname.split('.')[0]; + if (h && !hostHints.includes(h) && hostHints.length < 6) { + hostHints.push(h); + } + } catch { + // ignore parse errors + } + } + } + + return { + entryCount: raw.length, + urlCount, + hasStun, + hasTurn, + hasTurns, + hostHints, + }; +} + +let lastTurnProbeAt = 0; + +/** + * Logs one privacy-safe row about TURN reachability after `checkTurnServers()`. + * Uses a short-lived `RTCPeerConnection` gather test — does not replace full call media. + */ +export async function probeMatrixTurnServerReadiness(options: { + client: MatrixClient; + roomId: string; + kind?: 'audio' | 'video'; +}): Promise { + if (typeof RTCPeerConnection === 'undefined') return; + const { client, roomId, kind } = options; + const now = Date.now(); + if (now - lastTurnProbeAt < TURN_PROBE_LOG_THROTTLE_MS) return; + lastTurnProbeAt = now; + + let turnCredsOk = false; + try { + const r = await client.checkTurnServers(); + turnCredsOk = r === true; + } catch { + turnCredsOk = false; + } + + const expiry = client.getTurnServersExpiry(); + const ttlSecApprox = + Number.isFinite(expiry) && expiry > 0 + ? Math.max(0, Math.round((expiry - now) / 1000)) + : 0; + + const raw = client.getTurnServers() as RTCIceServer[]; + const iceSummary = summarizeMatrixIceServers(raw); + const forceTurn = Boolean( + (client as { forceTURN?: boolean }).forceTURN ?? false, + ); + const fallbackAllowed = Boolean( + client.isFallbackICEServerAllowed?.() ?? false, + ); + + logSpaceGroupCallEvent({ + name: 'hypha.group_call.turn_probe', + roomId, + kind, + turnCredsOk, + turnTtlSecApprox: ttlSecApprox, + iceEntryCount: iceSummary.entryCount, + iceUrlCount: iceSummary.urlCount, + iceHasStun: iceSummary.hasStun, + iceHasTurn: iceSummary.hasTurn, + iceHasTurns: iceSummary.hasTurns, + iceHostHints: iceSummary.hostHints.length + ? iceSummary.hostHints + : undefined, + forceTurn, + fallbackIceAllowed: fallbackAllowed, + }); + + if (!iceSummary.hasTurn && !iceSummary.hasTurns && !fallbackAllowed) { + /** Quick gather sanity check — confirms browser can reach at least STUN if configured. */ + let gatherState: RTCIceGatheringState | 'unsupported' | 'timeout' = + 'unsupported'; + try { + const pc = new RTCPeerConnection({ + iceServers: raw.length ? raw : [], + }); + gatherState = pc.iceGatheringState; + await new Promise((resolve) => { + const schedule = globalThis.setTimeout.bind(globalThis); + const cancel = globalThis.clearTimeout.bind(globalThis); + const t = schedule(() => { + cleanup(); + gatherState = 'timeout'; + resolve(); + }, 4500); + const cleanup = () => { + cancel(t); + pc.onicegatheringstatechange = null; + try { + pc.close(); + } catch { + /* ignore */ + } + }; + pc.onicegatheringstatechange = () => { + gatherState = pc.iceGatheringState; + if (pc.iceGatheringState === 'complete') { + cleanup(); + resolve(); + } + }; + try { + pc.createDataChannel('probe', { ordered: false }); + } catch { + /* ignore */ + } + void pc.createOffer().then((o) => pc.setLocalDescription(o)); + }); + } catch { + gatherState = 'unsupported'; + } + + logSpaceGroupCallEvent({ + name: 'hypha.group_call.ice_gather_probe', + roomId, + kind, + iceGatherState: gatherState, + }); + } +} + +export type GroupCallDiagnosticsCleanup = () => void; + +/** + * Enables Matrix SDK summary stats on the group call and forwards a privacy-safe subset to console telemetry. + */ +export function attachGroupCallWebRtcDiagnostics(options: { + gc: GroupCall; + roomId: string; + summaryStatsIntervalMs: number; +}): GroupCallDiagnosticsCleanup { + const { gc, roomId, summaryStatsIntervalMs } = options; + + if (summaryStatsIntervalMs > 0) { + gc.setGroupCallStatsInterval(summaryStatsIntervalMs); + } + + const onSummary = (payload: { report: GroupCallSummaryStatsReport }) => { + const r = payload.report; + logSpaceGroupCallEvent({ + name: 'hypha.group_call.webrtc_summary', + roomId, + groupCallId: gc.groupCallId, + percentageReceivedMedia: r.percentageReceivedMedia, + percentageReceivedAudioMedia: r.percentageReceivedAudioMedia, + percentageReceivedVideoMedia: r.percentageReceivedVideoMedia, + maxJitter: r.maxJitter, + maxPacketLoss: r.maxPacketLoss, + peerConnections: r.peerConnections, + opponentUsersInCall: r.opponentUsersInCall, + opponentDevicesInCall: r.opponentDevicesInCall, + diffDevicesToPeerConnections: r.diffDevicesToPeerConnections, + ratioPeerConnectionToDevices: r.ratioPeerConnectionToDevices, + }); + }; + + gc.on(GroupCallStatsReportEvent.SummaryStats, onSummary); + + return () => { + gc.removeListener(GroupCallStatsReportEvent.SummaryStats, onSummary); + if (summaryStatsIntervalMs > 0) { + gc.setGroupCallStatsInterval(0); + } + }; +} diff --git a/packages/core/src/matrix/client/hooks/space-group-call-telemetry.ts b/packages/core/src/matrix/client/hooks/space-group-call-telemetry.ts index 94837ba6d1..f5c49400e0 100644 --- a/packages/core/src/matrix/client/hooks/space-group-call-telemetry.ts +++ b/packages/core/src/matrix/client/hooks/space-group-call-telemetry.ts @@ -1,19 +1,69 @@ /** * Privacy-safe, client-only telemetry for group calls. No PII; room id is - * a Matrix opaque id. Enable debug logs in dev via the browser console filter + * a Matrix opaque id. Enable debug logs in dev, or in preview with + * `NEXT_PUBLIC_MATRIX_WEBRTC_DEBUG=true`, via the browser console filter * "hypha.group_call". */ +import { matrixWebRtcDebugFromEnv } from '../matrix-webrtc-env'; + export type SpaceGroupCallTelemetryEvent = { name: | 'hypha.group_call.join_ms' | 'hypha.group_call.left' - | 'hypha.group_call.error'; + | 'hypha.group_call.error' + | 'hypha.group_call.connected' + | 'hypha.group_call.media_snapshot' + | 'hypha.group_call.remote_media_stall' + | 'hypha.group_call.turn_probe' + | 'hypha.group_call.ice_gather_probe' + | 'hypha.group_call.webrtc_summary' + | 'hypha.group_call.room_type_sync'; roomId: string; kind?: 'audio' | 'video'; + /** Matrix group call state event `m.type` before sync (voice→video upgrade). */ + previousRoomGroupCallType?: string; + /** Matrix group call state event `m.type` after sync. */ + roomGroupCallType?: string; joinMs?: number; errorCode?: string; reason?: 'user' | 'error' | 'room' | 'unmount'; + /** Matrix group call id (opaque); helps confirm both peers share one session. */ + groupCallId?: string; + userMediaFeedCount?: number; + remoteUserMediaFeedCount?: number; + screenshareFeedCount?: number; + participantDeviceCount?: number; + /** Room state lists them in-call but no userMedia CallFeed yet (WebRTC lag / failure). */ + missingRemoteFeedCount?: number; + waitedMs?: number; + /** Result of `client.checkTurnServers()` — homeserver returned usable TURN URIs. */ + turnCredsOk?: boolean; + /** Approximate seconds until TURN credential expiry (from client clock). */ + turnTtlSecApprox?: number; + /** From `client.getTurnServers()` mapped for RTCPeerConnection — counts only; no URIs/credentials. */ + iceEntryCount?: number; + iceUrlCount?: number; + iceHasStun?: boolean; + iceHasTurn?: boolean; + iceHasTurns?: boolean; + /** First hostname label samples from ICE URLs (privacy-safe hints for infra debugging). */ + iceHostHints?: string[]; + forceTurn?: boolean; + fallbackIceAllowed?: boolean; + iceGatherState?: RTCIceGatheringState | 'unsupported' | 'timeout'; + /** Matrix SDK summary stats (subset); see `SummaryStatsReport`. */ + percentageReceivedMedia?: number; + percentageReceivedAudioMedia?: number; + percentageReceivedVideoMedia?: number; + maxJitter?: number; + maxPacketLoss?: number; + percentageConcealedAudio?: number; + peerConnections?: number; + opponentUsersInCall?: number; + opponentDevicesInCall?: number; + diffDevicesToPeerConnections?: number; + ratioPeerConnectionToDevices?: number; }; export function logSpaceGroupCallEvent( @@ -23,7 +73,7 @@ export function logSpaceGroupCallEvent( return; } try { - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === 'development' || matrixWebRtcDebugFromEnv()) { console.info('[hypha.group_call]', event); } } catch { diff --git a/packages/core/src/matrix/client/hooks/space-group-call-utils.ts b/packages/core/src/matrix/client/hooks/space-group-call-utils.ts index ca4c54db7f..e21d10a48b 100644 --- a/packages/core/src/matrix/client/hooks/space-group-call-utils.ts +++ b/packages/core/src/matrix/client/hooks/space-group-call-utils.ts @@ -12,8 +12,9 @@ export function isPermissionLikeGroupCallError(e: unknown): boolean { if ( name === 'NotAllowedError' || name === 'PermissionDismissedError' /* experimental */ || - name === 'NotReadableError' /* often used when device busy */ || - name === 'OverconstrainedError' /* can follow denied constraints */ + name === 'SecurityError' || + name === 'NotReadableError' || + name === 'OverconstrainedError' ) { return true; } diff --git a/packages/core/src/matrix/client/hooks/use-matrix-user-ids-by-privy-subs.ts b/packages/core/src/matrix/client/hooks/use-matrix-user-ids-by-privy-subs.ts index 9db7fdd020..bdf8e63b88 100644 --- a/packages/core/src/matrix/client/hooks/use-matrix-user-ids-by-privy-subs.ts +++ b/packages/core/src/matrix/client/hooks/use-matrix-user-ids-by-privy-subs.ts @@ -6,6 +6,9 @@ import useSWR from 'swr'; import { getMatrixUserIdsByPrivySubsAction } from '../../server/actions'; +/** Stable fallback — `data ?? {}` was a new object each render and broke referential equality downstream. */ +const EMPTY_SUB_TO_MATRIX: Record = {}; + export interface UseMatrixUserIdsByPrivySubsInput { /** Privy `sub` values from Hypha `Person.sub` (omit empty). */ privySubs?: string[]; @@ -47,10 +50,12 @@ export const useMatrixUserIdsByPrivySubs = ({ } return map; }, + /** Avoid flashing Privy/Matrix technical locals when JWT refreshes or focus revalidation races. */ + { keepPreviousData: true }, ); return { - subToMatrixUserId: data ?? {}, + subToMatrixUserId: data ?? EMPTY_SUB_TO_MATRIX, isLoading: isLoadingJwt || isLoading, error, }; diff --git a/packages/core/src/matrix/client/hooks/use-space-group-call.ts b/packages/core/src/matrix/client/hooks/use-space-group-call.ts index 441a5daf35..527ad749d5 100644 --- a/packages/core/src/matrix/client/hooks/use-space-group-call.ts +++ b/packages/core/src/matrix/client/hooks/use-space-group-call.ts @@ -2,11 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as MatrixSdk from 'matrix-js-sdk'; -import { RoomStateEvent } from 'matrix-js-sdk'; +import { ClientEvent, RoomStateEvent } from 'matrix-js-sdk'; import { GroupCallEventHandlerEvent } from 'matrix-js-sdk/lib/webrtc/groupCallEventHandler'; import { useMatrix } from '../providers/matrix-provider'; import { isPermissionLikeGroupCallError } from './space-group-call-utils'; import { logSpaceGroupCallEvent } from './space-group-call-telemetry'; +import { matrixGroupCallSummaryStatsMsFromEnv } from '../matrix-webrtc-env'; +import { + attachGroupCallWebRtcDiagnostics, + probeMatrixTurnServerReadiness, +} from './group-call-webrtc-diagnostics'; import type { SpaceGroupCallState } from './space-group-call-state'; export type { SpaceGroupCallState } from './space-group-call-state'; @@ -16,12 +21,45 @@ export type SpaceGroupCallErrorCode = | 'NO_ROOM' | 'NOT_READY' | 'PERMISSION_DENIED' + | 'CONNECT_STALL' | 'WEBRTC_FAILED' | 'UNKNOWN'; const { GroupCallEvent, GroupCallIntent, GroupCallType, GroupCallState } = MatrixSdk; +/** Abort `gc.enter()` hang (SFU/TURN stuck) — user-recoverable via Retry. */ +const CONNECT_STALL_ABORT_MS = 90_000; + +/** Room shows others in-call but no remote userMedia CallFeed yet (signaling/WebRTC issue). */ +const REMOTE_MEDIA_STALL_MS = 45_000; + +/** Dev console: periodic sample of feeds vs participant map (not every Matrix event). */ +const MEDIA_SNAPSHOT_INTERVAL_MS = 12_000; + +/** + * Matrix group calls use pairwise VoIP: the lexicographically higher MXID places + * outbound `m.call.*` to the lower. `placeOutgoingCalls()` runs on participant + * updates; a tight race on join can skip the first attempt and leave only one + * side with media until reload. Nudge once after enter + optional delayed retry. + */ +const PLACE_OUTGOING_DELAYED_MS = 600; +const PLACE_OUTGOING_RETRY_MS = [1500, 4000, 8000] as const; + +function nudgeGroupCallPlaceOutgoing(gc: MatrixSdk.GroupCall): void { + const fn = (gc as unknown as { placeOutgoingCalls?: () => void }) + .placeOutgoingCalls; + if (typeof fn !== 'function') return; + try { + fn.call(gc); + } catch { + /* ignore */ + } +} + +/** Matrix SDK group-call summary stats interval (`NEXT_PUBLIC_MATRIX_WEBRTC_GROUP_STATS_MS`). */ +const GROUP_WEBRTC_SUMMARY_STATS_MS = matrixGroupCallSummaryStatsMsFromEnv(); + /** `callSessionId` for correlation; must not use `Math.random()` (CodeQL / GAS-weak-randomness). */ function newCallSessionId(): string { const c = globalThis.crypto; @@ -87,6 +125,35 @@ export function useSpaceGroupCall(roomId: string | null) { const lastRoomIdForTelemetryRef = useRef(null); const activeGroupCallRoomIdRef = useRef(null); const loggedStatsForGroupCallIdRef = useRef(null); + const webRtcDiagCleanupRef = useRef<(() => void) | null>(null); + const groupCallListenerCleanupRef = useRef<(() => void) | null>(null); + /** Cleared in runCleanup — delayed second `placeOutgoingCalls` nudge after enter(). */ + const placeOutgoingNudgeTimerRef = useRef(null); + /** Additional pairwise call-placement retries for rejoin/refresh races. */ + const placeOutgoingRetryTimerRefs = useRef([]); + /** + * Bumped when starting a join and when the stall watchdog fires — stale + * `await gc.enter()` must not run success paths after forced cleanup. + */ + const joinEpochRef = useRef(0); + /** Cleared on enter or teardown — abort endless "Connecting…" when enter() hangs. */ + const connectingStallTimerRef = useRef | null>( + null, + ); + /** Dev / support: periodic media snapshots while connected (`setInterval`). */ + const mediaDebugIntervalRef = useRef | null>( + null, + ); + /** First time we saw others in participant map but no remote CallFeed (ms since epoch). */ + const remoteMediaGapSinceRef = useRef(null); + const remoteMediaStallLoggedRef = useRef(false); + const remoteMediaStallBannerDismissedRef = useRef(false); + const [remoteMediaStall, setRemoteMediaStall] = useState(false); + + const dismissRemoteMediaStallBanner = useCallback(() => { + remoteMediaStallBannerDismissedRef.current = true; + setRemoteMediaStall(false); + }, []); const [tabBackgroundWhileInCall, setTabBackgroundWhileInCall] = useState(false); /** @@ -114,25 +181,47 @@ export function useSpaceGroupCall(roomId: string | null) { }); }, []); + const clearConnectingStallTimer = useCallback(() => { + if (connectingStallTimerRef.current != null) { + clearTimeout(connectingStallTimerRef.current); + connectingStallTimerRef.current = null; + } + }, []); + + const clearMediaDebugInterval = useCallback(() => { + if (mediaDebugIntervalRef.current != null) { + clearInterval(mediaDebugIntervalRef.current); + mediaDebugIntervalRef.current = null; + } + }, []); + const runCleanup = useCallback(() => { + clearConnectingStallTimer(); + clearMediaDebugInterval(); + if (placeOutgoingNudgeTimerRef.current != null) { + clearTimeout(placeOutgoingNudgeTimerRef.current); + placeOutgoingNudgeTimerRef.current = null; + } + if (placeOutgoingRetryTimerRefs.current.length > 0) { + for (const id of placeOutgoingRetryTimerRefs.current) { + clearTimeout(id); + } + placeOutgoingRetryTimerRefs.current = []; + } + webRtcDiagCleanupRef.current?.(); + webRtcDiagCleanupRef.current = null; + groupCallListenerCleanupRef.current?.(); + groupCallListenerCleanupRef.current = null; + remoteMediaGapSinceRef.current = null; + remoteMediaStallLoggedRef.current = false; + remoteMediaStallBannerDismissedRef.current = false; + setRemoteMediaStall(false); if (feedUpdateRafRef.current != null) { cancelAnimationFrame(feedUpdateRafRef.current); feedUpdateRafRef.current = null; } const gc = groupCallRef.current; if (gc) { - try { - gc.removeAllListeners(GroupCallEvent.Error); - gc.removeAllListeners(GroupCallEvent.GroupCallStateChanged); - gc.removeAllListeners(GroupCallEvent.LocalScreenshareStateChanged); - gc.removeAllListeners(GroupCallEvent.ParticipantsChanged); - gc.removeAllListeners(GroupCallEvent.UserMediaFeedsChanged); - gc.removeAllListeners(GroupCallEvent.ScreenshareFeedsChanged); - gc.removeAllListeners(GroupCallEvent.LocalMuteStateChanged); - gc.removeAllListeners(GroupCallEvent.ActiveSpeakerChanged); - } catch { - // ignore - } const p = (async () => { try { await Promise.resolve((gc as MatrixSdk.GroupCall).leave()); @@ -141,6 +230,20 @@ export function useSpaceGroupCall(roomId: string | null) { console.debug('[hypha.group_call] GroupCall.leave() rejected', err); } } + try { + await Promise.resolve( + ( + gc as MatrixSdk.GroupCall & { cleanMemberState?: () => void } + ).cleanMemberState?.(), + ); + } catch (err) { + if (process.env.NODE_ENV === 'development') { + console.debug( + '[hypha.group_call] GroupCall.cleanMemberState() rejected', + err, + ); + } + } })(); leaveInFlightRef.current = p; p.finally(() => { @@ -161,7 +264,7 @@ export function useSpaceGroupCall(roomId: string | null) { setScreenshareErrorCode(null); loggedStatsForGroupCallIdRef.current = null; lastRoomIdForTelemetryRef.current = null; - }, []); + }, [clearConnectingStallTimer, clearMediaDebugInterval]); const refreshLocalPreview = useCallback(() => { const gc = groupCallRef.current; @@ -218,8 +321,102 @@ export function useSpaceGroupCall(roomId: string | null) { [readParticipantsFromGroupCall], ); + /** Stall detection: others in participant map but no remote userMedia CallFeed (WebRTC lag). */ + const evalRemoteMediaStall = useCallback(() => { + const gc = groupCallRef.current; + if (!gc || !roomId?.trim() || !client) return; + const myId = client.getUserId() ?? null; + const remoteFeeds = gc.userMediaFeeds.filter((f) => !f.isLocal()); + const remoteIdsWithFeed = new Set( + remoteFeeds.map((f) => f.userId).filter(Boolean) as string[], + ); + const othersInCall = inCallUserIdsFromGroupCall(gc).filter( + (id) => id && id !== myId, + ); + const missingRemoteFeedCount = othersInCall.filter( + (id) => !remoteIdsWithFeed.has(id), + ).length; + + const now = Date.now(); + if (missingRemoteFeedCount > 0 && othersInCall.length > 0) { + /** + * The SDK can know the remote participant from group-call member state + * while the pairwise `MatrixCall` never finishes selecting an opponent + * (candidates get buffered, no CallFeed arrives). Retry placement from + * the stalled side as well as from ParticipantsChanged/room-state bumps. + */ + nudgeGroupCallPlaceOutgoing(gc); + if (remoteMediaGapSinceRef.current == null) { + remoteMediaGapSinceRef.current = now; + } + const waitedMs = now - remoteMediaGapSinceRef.current; + if ( + waitedMs >= REMOTE_MEDIA_STALL_MS && + !remoteMediaStallLoggedRef.current + ) { + remoteMediaStallLoggedRef.current = true; + logSpaceGroupCallEvent({ + name: 'hypha.group_call.remote_media_stall', + roomId, + kind: lastJoinKindRef.current ?? undefined, + groupCallId: gc.groupCallId, + missingRemoteFeedCount, + waitedMs, + }); + if (!remoteMediaStallBannerDismissedRef.current) { + setRemoteMediaStall(true); + } + } + } else { + remoteMediaGapSinceRef.current = null; + remoteMediaStallLoggedRef.current = false; + remoteMediaStallBannerDismissedRef.current = false; + setRemoteMediaStall(false); + } + }, [ + client, + roomId, + inCallUserIdsFromGroupCall, + readParticipantsFromGroupCall, + ]); + + const logDevMediaSnapshot = useCallback(() => { + const gc = groupCallRef.current; + if (!gc || !roomId?.trim() || !client) return; + const myId = client.getUserId() ?? null; + const remoteFeeds = gc.userMediaFeeds.filter((f) => !f.isLocal()); + const remoteIdsWithFeed = new Set( + remoteFeeds.map((f) => f.userId).filter(Boolean) as string[], + ); + const othersInCall = inCallUserIdsFromGroupCall(gc).filter( + (id) => id && id !== myId, + ); + const missingRemoteFeedCount = othersInCall.filter( + (id) => !remoteIdsWithFeed.has(id), + ).length; + logSpaceGroupCallEvent({ + name: 'hypha.group_call.media_snapshot', + roomId, + kind: lastJoinKindRef.current ?? undefined, + groupCallId: gc.groupCallId, + userMediaFeedCount: gc.userMediaFeeds.length, + remoteUserMediaFeedCount: remoteFeeds.length, + screenshareFeedCount: gc.screenshareFeeds.length, + participantDeviceCount: readParticipantsFromGroupCall(gc).count, + missingRemoteFeedCount, + }); + }, [ + client, + roomId, + inCallUserIdsFromGroupCall, + readParticipantsFromGroupCall, + ]); + const attachGroupCallListeners = useCallback( (gc: MatrixSdk.GroupCall) => { + groupCallListenerCleanupRef.current?.(); + groupCallListenerCleanupRef.current = null; + const onError = (err: unknown) => { const isPerm = isPermissionLikeGroupCallError(err); const code: SpaceGroupCallErrorCode = isPerm @@ -253,15 +450,24 @@ export function useSpaceGroupCall(roomId: string | null) { } }; gc.on(GroupCallEvent.GroupCallStateChanged, onState); - gc.on(GroupCallEvent.LocalScreenshareStateChanged, (sharing: boolean) => { + const onLocalScreenshareStateChanged = (sharing: boolean) => { setIsScreensharing(sharing); - }); - gc.on(GroupCallEvent.ParticipantsChanged, () => { + }; + gc.on( + GroupCallEvent.LocalScreenshareStateChanged, + onLocalScreenshareStateChanged, + ); + const onParticipantsChanged = () => { updateParticipantCount(); - }); + evalRemoteMediaStall(); + /** Pairwise VoIP: roster changes can arrive after the internal nudge — retry outbound setup. */ + nudgeGroupCallPlaceOutgoing(gc); + }; + gc.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged); const onFeedsMaybeParticipants = () => { scheduleFeedBatched(); updateParticipantCount(); + evalRemoteMediaStall(); }; gc.on(GroupCallEvent.UserMediaFeedsChanged, onFeedsMaybeParticipants); gc.on(GroupCallEvent.ScreenshareFeedsChanged, onFeedsMaybeParticipants); @@ -274,13 +480,40 @@ export function useSpaceGroupCall(roomId: string | null) { }; gc.on(GroupCallEvent.ActiveSpeakerChanged, onActiveSpeaker); onActiveSpeaker(gc.activeSpeaker); - gc.on( - GroupCallEvent.LocalMuteStateChanged, - (audioMuted: boolean, videoMuted: boolean) => { - setIsMicrophoneMuted(audioMuted); - setIsLocalVideoMuted(videoMuted); - }, - ); + const onLocalMuteStateChanged = ( + audioMuted: boolean, + videoMuted: boolean, + ) => { + setIsMicrophoneMuted(audioMuted); + setIsLocalVideoMuted(videoMuted); + }; + gc.on(GroupCallEvent.LocalMuteStateChanged, onLocalMuteStateChanged); + + groupCallListenerCleanupRef.current = () => { + gc.removeListener(GroupCallEvent.Error, onError); + gc.removeListener(GroupCallEvent.GroupCallStateChanged, onState); + gc.removeListener( + GroupCallEvent.LocalScreenshareStateChanged, + onLocalScreenshareStateChanged, + ); + gc.removeListener( + GroupCallEvent.ParticipantsChanged, + onParticipantsChanged, + ); + gc.removeListener( + GroupCallEvent.UserMediaFeedsChanged, + onFeedsMaybeParticipants, + ); + gc.removeListener( + GroupCallEvent.ScreenshareFeedsChanged, + onFeedsMaybeParticipants, + ); + gc.removeListener(GroupCallEvent.ActiveSpeakerChanged, onActiveSpeaker); + gc.removeListener( + GroupCallEvent.LocalMuteStateChanged, + onLocalMuteStateChanged, + ); + }; }, [ roomId, @@ -288,6 +521,7 @@ export function useSpaceGroupCall(roomId: string | null) { runCleanup, scheduleFeedBatched, updateParticipantCount, + evalRemoteMediaStall, ], ); @@ -312,6 +546,9 @@ export function useSpaceGroupCall(roomId: string | null) { setIdleRoomParticipantCount(0); setIdleInCallUserIds([]); + joinEpochRef.current += 1; + const joinEpoch = joinEpochRef.current; + isJoiningRef.current = true; const newSessionId = newCallSessionId(); setCallSessionId(newSessionId); @@ -352,6 +589,22 @@ export function useSpaceGroupCall(roomId: string | null) { const type = kind === 'video' ? GroupCallType.Video : GroupCallType.Voice; let gc = client.getGroupCallForRoom(roomId); + if (gc) { + const myId = client.getUserId() ?? null; + const activeOthers = readParticipantsFromGroupCall(gc, myId).count; + if (activeOthers === 0) { + try { + await Promise.resolve( + ( + gc as MatrixSdk.GroupCall & { terminate?: () => void } + ).terminate?.(), + ); + } catch { + /* stale local group call cleanup is best-effort */ + } + gc = client.getGroupCallForRoom(roomId); + } + } if (!gc) { setCallState('connecting'); @@ -410,6 +663,53 @@ export function useSpaceGroupCall(roomId: string | null) { return; } + /** + * Voice vs video share one room group call. If the first joiner created + * `m.voice`, the SDK only requests camera when `type === Video`. Upgrade + * room state to `m.video` when joining with video. Never downgrade to + * `m.voice` if the call is already video (audio join = local video off only). + */ + if (kind === 'video' && gc.type !== GroupCallType.Video) { + const prevType = gc.type; + /** SDK method is private on `GroupCall`; intersecting types collapses to `never`. */ + const gcSync = gc as unknown as { + type: MatrixSdk.GroupCall['type']; + sendCallStateEvent(): Promise; + }; + gcSync.type = GroupCallType.Video; + try { + await gcSync.sendCallStateEvent(); + if (roomId) { + logSpaceGroupCallEvent({ + name: 'hypha.group_call.room_type_sync', + roomId, + kind, + groupCallId: gc.groupCallId, + previousRoomGroupCallType: String(prevType), + roomGroupCallType: String(GroupCallType.Video), + }); + } + } catch { + gcSync.type = prevType; + isJoiningRef.current = false; + setErrorCode('UNKNOWN'); + setCallSessionId(null); + if (roomId) { + logSpaceGroupCallEvent({ + name: 'hypha.group_call.error', + roomId, + kind, + errorCode: 'ROOM_TYPE_SYNC', + }); + } + setCallState('error'); + setCallKind(null); + setThreadContext(null); + joinStartedAtRef.current = null; + return; + } + } + type GroupCallPreEnterMute = { initWithVideoMuted: boolean; initWithAudioMuted: boolean; @@ -417,6 +717,19 @@ export function useSpaceGroupCall(roomId: string | null) { const gci = gc as unknown as GroupCallPreEnterMute; gci.initWithVideoMuted = kind === 'audio'; gci.initWithAudioMuted = false; + /** + * Refresh stale local member state after hard reloads. If the prior tab died + * mid-call, old device entries can survive briefly and cause asymmetric media. + */ + try { + await Promise.resolve( + ( + gc as MatrixSdk.GroupCall & { cleanMemberState?: () => void } + ).cleanMemberState?.(), + ); + } catch { + /* best-effort pre-enter cleanup */ + } groupCallRef.current = gc; activeGroupCallRoomIdRef.current = roomId; @@ -425,9 +738,48 @@ export function useSpaceGroupCall(roomId: string | null) { updateParticipantCount(); setCallState('connecting'); + /** + * Probe TURN before `enter()`: missing homeserver TURN config often makes + * `gc.enter()` stall, so post-enter diagnostics would never be emitted. + */ + void probeMatrixTurnServerReadiness({ client, roomId, kind }); + + clearConnectingStallTimer(); + connectingStallTimerRef.current = setTimeout(() => { + clearConnectingStallTimer(); + joinEpochRef.current += 1; + if (groupCallRef.current !== gc) return; + if (process.env.NODE_ENV === 'development') { + console.warn('[hypha.group_call] enter() stalled — forcing cleanup', { + roomId, + ms: CONNECT_STALL_ABORT_MS, + }); + } + isJoiningRef.current = false; + setErrorCode('CONNECT_STALL'); + if (roomId) { + logSpaceGroupCallEvent({ + name: 'hypha.group_call.error', + roomId, + kind, + errorCode: 'CONNECT_STALL', + }); + } + setCallState('error'); + runCleanup(); + setCallKind(null); + setThreadContext(null); + joinStartedAtRef.current = null; + }, CONNECT_STALL_ABORT_MS); + try { await gc.enter(); } catch (e) { + clearConnectingStallTimer(); + if (joinEpoch !== joinEpochRef.current || groupCallRef.current !== gc) { + isJoiningRef.current = false; + return; + } isJoiningRef.current = false; const permissionLike = isPermissionLikeGroupCallError(e); if (permissionLike) { @@ -451,6 +803,55 @@ export function useSpaceGroupCall(roomId: string | null) { return; } + clearConnectingStallTimer(); + + if (joinEpoch !== joinEpochRef.current || groupCallRef.current !== gc) { + isJoiningRef.current = false; + return; + } + + /** + * Voice→video: `enter()` used `initWithVideoMuted` from our audio intent earlier in + * this session, or upgraded from voice room state — request camera explicitly so + * outbound video negotiates after room `m.type` is video. + */ + if (kind === 'video') { + try { + await gc.setLocalVideoMuted(false); + } catch { + /* camera permission / hardware — remain in call with video off */ + } + } + + nudgeGroupCallPlaceOutgoing(gc); + if (typeof window !== 'undefined') { + if (placeOutgoingNudgeTimerRef.current != null) { + clearTimeout(placeOutgoingNudgeTimerRef.current); + } + placeOutgoingNudgeTimerRef.current = window.setTimeout(() => { + placeOutgoingNudgeTimerRef.current = null; + if (groupCallRef.current !== gc) return; + nudgeGroupCallPlaceOutgoing(gc); + }, PLACE_OUTGOING_DELAYED_MS); + placeOutgoingRetryTimerRefs.current = PLACE_OUTGOING_RETRY_MS.map( + (delayMs) => + window.setTimeout(() => { + if (groupCallRef.current !== gc) return; + nudgeGroupCallPlaceOutgoing(gc); + }, delayMs), + ); + } + + webRtcDiagCleanupRef.current?.(); + webRtcDiagCleanupRef.current = null; + if (GROUP_WEBRTC_SUMMARY_STATS_MS > 0) { + webRtcDiagCleanupRef.current = attachGroupCallWebRtcDiagnostics({ + gc, + roomId, + summaryStatsIntervalMs: GROUP_WEBRTC_SUMMARY_STATS_MS, + }); + } + setCallState('connected'); refreshLocalPreview(); updateParticipantCount(); @@ -459,6 +860,18 @@ export function useSpaceGroupCall(roomId: string | null) { setActiveKeyFromGroupCall(gc); isJoiningRef.current = false; lastRoomIdForTelemetryRef.current = roomId; + + if (roomId) { + logSpaceGroupCallEvent({ + name: 'hypha.group_call.connected', + roomId, + kind, + groupCallId: gc.groupCallId, + }); + } + logDevMediaSnapshot(); + evalRemoteMediaStall(); + const t1 = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (joinStartedAtRef.current != null) { @@ -495,8 +908,11 @@ export function useSpaceGroupCall(roomId: string | null) { roomId, refreshLocalPreview, runCleanup, + readParticipantsFromGroupCall, setActiveKeyFromGroupCall, updateParticipantCount, + logDevMediaSnapshot, + evalRemoteMediaStall, ], ); @@ -542,6 +958,7 @@ export function useSpaceGroupCall(roomId: string | null) { await gc.setMicrophoneMuted(muted); setIsMicrophoneMuted(gc.isMicrophoneMuted()); scheduleFeedBatched(); + window.setTimeout(scheduleFeedBatched, 350); }, [scheduleFeedBatched], ); @@ -550,11 +967,47 @@ export function useSpaceGroupCall(roomId: string | null) { async (muted: boolean) => { const gc = groupCallRef.current; if (!gc) return; + if (!muted && gc.type !== GroupCallType.Video) { + const prevType = gc.type; + const gcSync = gc as unknown as { + type: MatrixSdk.GroupCall['type']; + sendCallStateEvent(): Promise; + }; + gcSync.type = GroupCallType.Video; + try { + await gcSync.sendCallStateEvent(); + if (roomId) { + logSpaceGroupCallEvent({ + name: 'hypha.group_call.room_type_sync', + roomId, + kind: lastJoinKindRef.current ?? undefined, + groupCallId: gc.groupCallId, + previousRoomGroupCallType: String(prevType), + roomGroupCallType: String(GroupCallType.Video), + }); + } + } catch { + gcSync.type = prevType; + } + } await gc.setLocalVideoMuted(muted); + if (!muted) { + setCallKind('video'); + lastJoinKindRef.current = 'video'; + nudgeGroupCallPlaceOutgoing(gc); + } setIsLocalVideoMuted(gc.isLocalVideoMuted()); + refreshLocalPreview(); scheduleFeedBatched(); + window.setTimeout(() => { + if (groupCallRef.current === gc && !gc.isLocalVideoMuted()) { + nudgeGroupCallPlaceOutgoing(gc); + } + refreshLocalPreview(); + scheduleFeedBatched(); + }, 350); }, - [scheduleFeedBatched], + [refreshLocalPreview, roomId, scheduleFeedBatched], ); const setScreensharingEnabled = useCallback( @@ -611,7 +1064,8 @@ export function useSpaceGroupCall(roomId: string | null) { /** * Room member `m.call.*` state is applied asynchronously in the GroupCall. When * `updateParticipants` runs, `participants` updates but `ParticipantsChanged` - * may not re-fire. Re-sync the banner count on every room state update while in a call. + * may not re-fire. Re-sync the banner count and retry pairwise call placement + * on every room state update while in a call. */ useEffect(() => { if (!client || !roomId?.trim()) return; @@ -625,13 +1079,40 @@ export function useSpaceGroupCall(roomId: string | null) { const room = client.getRoom(roomId); if (!room) return; const bump = () => { + const gc = groupCallRef.current; + if (gc) { + nudgeGroupCallPlaceOutgoing(gc); + } updateParticipantCount(); + evalRemoteMediaStall(); }; room.on(RoomStateEvent.Update, bump); return () => { room.off(RoomStateEvent.Update, bump); }; - }, [client, roomId, callState, updateParticipantCount]); + }, [client, roomId, callState, updateParticipantCount, evalRemoteMediaStall]); + + /** Dev: periodic feed vs participant-map snapshots while connected. */ + useEffect(() => { + if (callState !== 'connected') { + clearMediaDebugInterval(); + return; + } + logDevMediaSnapshot(); + mediaDebugIntervalRef.current = setInterval(() => { + logDevMediaSnapshot(); + evalRemoteMediaStall(); + }, MEDIA_SNAPSHOT_INTERVAL_MS); + return () => { + clearMediaDebugInterval(); + }; + }, [ + callState, + roomId, + clearMediaDebugInterval, + logDevMediaSnapshot, + evalRemoteMediaStall, + ]); useEffect(() => { if (typeof document === 'undefined') return; @@ -735,6 +1216,26 @@ export function useSpaceGroupCall(roomId: string | null) { sync(); }; + /** + * Last member leaving updates `m.group_call_member` state without always firing + * `GroupCallEvent.ParticipantsChanged`, so the Join strip could stay stale. + * Debounce — member state can fan out several updates per sync. + */ + let idleRoomDebounce: ReturnType | null = null; + const bumpIdleFromRoomState = () => { + if (idleRoomDebounce != null) clearTimeout(idleRoomDebounce); + idleRoomDebounce = setTimeout(() => { + idleRoomDebounce = null; + sync(); + }, 150); + }; + + const roomObj = client.getRoom(roomId); + if (roomObj) { + roomObj.on(RoomStateEvent.Update, bumpIdleFromRoomState); + } + client.on(ClientEvent.Sync, bumpIdleFromRoomState); + /* Subscribe before the first `sync` so we do not miss `GroupCall.incoming` (was "alone until reload"). */ client.on( GroupCallEventHandlerEvent.Incoming, @@ -747,6 +1248,9 @@ export function useSpaceGroupCall(roomId: string | null) { sync(); const unsub = () => { + if (idleRoomDebounce != null) clearTimeout(idleRoomDebounce); + roomObj?.off(RoomStateEvent.Update, bumpIdleFromRoomState); + client.removeListener(ClientEvent.Sync, bumpIdleFromRoomState); unwatchParticipants(); client.removeListener( GroupCallEventHandlerEvent.Incoming, @@ -828,6 +1332,9 @@ export function useSpaceGroupCall(roomId: string | null) { dismissScreenshareError, dismissCallError, retryFromError, + /** Matrix lists others in-call but no remote media after threshold — likely WebRTC/signaling. */ + remoteMediaStall, + dismissRemoteMediaStallBanner, tabBackgroundWhileInCall, activeSpeakerKey, threadContext, diff --git a/packages/core/src/matrix/client/matrix-client-logger.ts b/packages/core/src/matrix/client/matrix-client-logger.ts new file mode 100644 index 0000000000..d5c947f5b1 --- /dev/null +++ b/packages/core/src/matrix/client/matrix-client-logger.ts @@ -0,0 +1,59 @@ +/** + * matrix-js-sdk defaults internal loggers to DEBUG, which floods the browser console + * during sync (push rules, sync queue, etc.) and makes DevTools sluggish. + * + * Pass this as `createClient({ logger })` so trace/debug/info/warn are silent unless + * `NEXT_PUBLIC_MATRIX_SDK_VERBOSE=true`. Errors always reach `console.error`. + */ +import type { Logger } from 'matrix-js-sdk/lib/logger'; + +function matrixSdkVerboseFromEnv(): boolean { + if (typeof process === 'undefined') return false; + const raw = process.env['NEXT_PUBLIC_MATRIX_SDK_VERBOSE']; + if (raw == null || raw.trim() === '') return false; + const v = raw.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes'; +} + +export function createHyphaMatrixClientLogger(): Logger { + const verbose = matrixSdkVerboseFromEnv(); + + const make = (prefix: string): Logger => { + return { + trace: (...args: unknown[]) => { + if (verbose) { + if (prefix) console.trace(prefix, ...args); + else console.trace(...args); + } + }, + debug: (...args: unknown[]) => { + if (verbose) { + if (prefix) console.debug(prefix, ...args); + else console.debug(...args); + } + }, + info: (...args: unknown[]) => { + if (verbose) { + if (prefix) console.info(prefix, ...args); + else console.info(...args); + } + }, + warn: (...args: unknown[]) => { + if (verbose) { + if (prefix) console.warn(prefix, ...args); + else console.warn(...args); + } + }, + error: (...args: unknown[]) => { + if (prefix) console.error(prefix, ...args); + else console.error(...args); + }, + getChild(namespace: string): Logger { + const next = prefix ? `${prefix}:${namespace}` : namespace; + return make(next); + }, + }; + }; + + return make(''); +} diff --git a/packages/core/src/matrix/client/matrix-webrtc-env.ts b/packages/core/src/matrix/client/matrix-webrtc-env.ts new file mode 100644 index 0000000000..334ec97dfb --- /dev/null +++ b/packages/core/src/matrix/client/matrix-webrtc-env.ts @@ -0,0 +1,74 @@ +/** + * Browser-visible WebRTC options for Matrix group calls (`createClient`). + * Credentials stay on the homeserver (`/voip/turnServer`); these toggles help + * deployments recover when TURN is disabled, missing, or relay-only paths are required. + */ + +function parseBool(raw: string | undefined, fallback: boolean): boolean { + if (raw == null || raw.trim() === '') return fallback; + const v = raw.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes'; +} + +function parseNonNegativeInt( + raw: string | undefined, + fallback: number, +): number { + if (raw == null || raw.trim() === '') return fallback; + const n = Number.parseInt(raw.trim(), 10); + if (!Number.isFinite(n) || n < 0) return fallback; + return n; +} + +/** Force relay (TURN) for all Matrix calls — default false. */ +export function matrixWebRtcForceTurnFromEnv(): boolean { + if (typeof process === 'undefined') return false; + return parseBool(process.env['NEXT_PUBLIC_MATRIX_WEBRTC_FORCE_TURN'], false); +} + +/** + * Emit privacy-safe Matrix group-call diagnostics outside local development. + * Enable only on preview/debug deployments. + */ +export function matrixWebRtcDebugFromEnv(): boolean { + if (typeof process === 'undefined') return false; + return parseBool(process.env['NEXT_PUBLIC_MATRIX_WEBRTC_DEBUG'], false); +} + +/** + * Allow public STUN fallback when the homeserver returns no ICE servers. + * matrix-js-sdk default is false — set to true only if your deployment allows it. + */ +export function matrixWebRtcFallbackIceAllowedFromEnv(): boolean { + if (typeof process === 'undefined') return false; + return parseBool( + process.env['NEXT_PUBLIC_MATRIX_WEBRTC_FALLBACK_ICE_ALLOWED'], + false, + ); +} + +/** + * ICE candidate pre-gather pool for faster first connect; 0 keeps SDK default. + */ +export function matrixWebRtcIceCandidatePoolSizeFromEnv(): number { + if (typeof process === 'undefined') return 0; + return Math.min( + parseNonNegativeInt( + process.env['NEXT_PUBLIC_MATRIX_WEBRTC_ICE_POOL_SIZE'], + 0, + ), + 255, + ); +} + +/** + * Matrix `GroupCall` periodic summary stats interval (ms). 0 disables. + * Emits `hypha.group_call.webrtc_summary` when > 0. + */ +export function matrixGroupCallSummaryStatsMsFromEnv(): number { + if (typeof process === 'undefined') return 0; + return parseNonNegativeInt( + process.env['NEXT_PUBLIC_MATRIX_WEBRTC_GROUP_STATS_MS'], + 0, + ); +} diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index 968a2fb590..ce9a3851e3 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -27,6 +27,12 @@ import { mergeMatrixMentionsIntoContent, resolveMentionUserIdsForSend, } from '../../mentions'; +import { + matrixWebRtcFallbackIceAllowedFromEnv, + matrixWebRtcForceTurnFromEnv, + matrixWebRtcIceCandidatePoolSizeFromEnv, +} from '../matrix-webrtc-env'; +import { createHyphaMatrixClientLogger } from '../matrix-client-logger'; export interface SendAttachmentInput { file: File; @@ -136,6 +142,73 @@ export const MATRIX_UPLOAD_TIMEOUT_MS = 120_000; const MATRIX_UPLOAD_STAGGER_MS = 400; const MATRIX_UPLOAD_RATE_LIMIT_MAX_ATTEMPTS = 4; +const MATRIX_GROUP_CALL_EVENT_TYPE = 'org.matrix.msc3401.call'; +const MATRIX_GROUP_CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member'; +const MATRIX_LEGACY_CALL_MEMBER_EVENT_TYPE = 'm.call.member'; + +async function ensureRoomCallPowerLevels( + client: MatrixSdk.MatrixClient, + roomId: string, +): Promise { + try { + const current = + (client + .getRoom(roomId) + ?.currentState.getStateEvents(MatrixSdk.EventType.RoomPowerLevels, '') + ?.getContent() as { events?: Record } | undefined) ?? + ((await client.getStateEvent( + roomId, + MatrixSdk.EventType.RoomPowerLevels, + '', + )) as { events?: Record }); + const events = { ...(current.events ?? {}) }; + if ( + events[MATRIX_GROUP_CALL_EVENT_TYPE] === 0 && + events[MATRIX_GROUP_CALL_MEMBER_EVENT_TYPE] === 0 && + events[MATRIX_LEGACY_CALL_MEMBER_EVENT_TYPE] === 0 + ) { + return; + } + await client.sendStateEvent( + roomId, + MatrixSdk.EventType.RoomPowerLevels, + { + ...current, + events: { + ...events, + [MATRIX_GROUP_CALL_EVENT_TYPE]: 0, + [MATRIX_GROUP_CALL_MEMBER_EVENT_TYPE]: 0, + [MATRIX_LEGACY_CALL_MEMBER_EVENT_TYPE]: 0, + }, + }, + '', + ); + } catch (error) { + const e = error as + | { + errcode?: string; + name?: string; + message?: string; + data?: { errcode?: string }; + httpStatus?: number; + } + | undefined; + const isPermissionDenied = + e?.errcode === 'M_FORBIDDEN' || + e?.data?.errcode === 'M_FORBIDDEN' || + e?.httpStatus === 403 || + e?.name === 'ForbiddenError'; + if (isPermissionDenied) { + console.warn( + 'Cannot ensure Matrix call power levels due to permissions:', + error, + ); + return; + } + console.warn('Cannot ensure Matrix call power levels:', error); + throw error; + } +} function delay(ms: number): Promise { return new Promise((resolve) => { @@ -393,12 +466,15 @@ export const MatrixProvider: React.FC = ({ children }) => { accessToken, userId, deviceId, + /** Default matrix-js-sdk log level is extremely chatty in the browser. */ + logger: createHyphaMatrixClientLogger(), disableVoip: false, - useE2eForGroupCall: true, + /** matrix-js-sdk v40 Rust crypto path cannot send encrypted group-call to-device VoIP events. */ + useE2eForGroupCall: false, useLivekitForGroupCalls: false, - forceTURN: false, - fallbackICEServerAllowed: false, - iceCandidatePoolSize: 0, + forceTURN: matrixWebRtcForceTurnFromEnv(), + fallbackICEServerAllowed: matrixWebRtcFallbackIceAllowedFromEnv(), + iceCandidatePoolSize: matrixWebRtcIceCandidatePoolSizeFromEnv(), }); await matrixClient.startClient(); @@ -409,7 +485,6 @@ export const MatrixProvider: React.FC = ({ children }) => { setActiveMatrixUserId(userId); setIsMatrixAvailable(matrixClient !== null); setIsAuthenticated(true); - console.log('Matrix client initialized'); } catch (error) { console.error('Failed to initialize Matrix client:', error); setClient(null); @@ -478,7 +553,21 @@ export const MatrixProvider: React.FC = ({ children }) => { preset: RoomPreset.PublicChat, name: title, topic: title, + initial_state: [ + { + type: MatrixSdk.EventType.RoomPowerLevels, + state_key: '', + content: { + events: { + [MATRIX_GROUP_CALL_EVENT_TYPE]: 0, + [MATRIX_GROUP_CALL_MEMBER_EVENT_TYPE]: 0, + [MATRIX_LEGACY_CALL_MEMBER_EVENT_TYPE]: 0, + }, + }, + }, + ], }); + await ensureRoomCallPowerLevels(client, roomId); return { roomId }; }, [client], @@ -1209,6 +1298,7 @@ export const MatrixProvider: React.FC = ({ children }) => { try { const joined = await client.joinRoom(roomIdOrAlias); const resolvedId = joined.roomId; + await ensureRoomCallPowerLevels(client, resolvedId); // `joinRoom` resolves before the lazy room store always exposes `getRoom` // (race with sync / canonical id). Wait briefly for `getRoom` parity with listeners. for (let i = 0; i < 40; i++) { @@ -1289,8 +1379,22 @@ export const MatrixProvider: React.FC = ({ children }) => { if (event.getRoomId() !== roomId) { return; } - const room = client.getRoom(roomId); const type = event.getType(); + /** + * `RoomEvent.Timeline` fires for **every** persisted event in the room during + * incremental sync (membership, typing, power levels, …). Human chat only cares + * about a handful — skipping early avoids heavy `findEventById` / reaction work + * per event and stops React from saturating on large backfills. + */ + if ( + type !== MatrixSdk.EventType.RoomMessage && + type !== MatrixSdk.EventType.RoomPinnedEvents && + type !== MatrixSdk.EventType.Reaction && + type !== MatrixSdk.EventType.RoomRedaction + ) { + return; + } + const room = client.getRoom(roomId); if (type === EventType.RoomMessage) { if (isRedactedRoomMessageEvent(event)) { diff --git a/packages/epics/src/common/human-chat-panel/human-chat-display-mention.ts b/packages/epics/src/common/human-chat-panel/human-chat-display-mention.ts new file mode 100644 index 0000000000..2e8c4f2a3b --- /dev/null +++ b/packages/epics/src/common/human-chat-panel/human-chat-display-mention.ts @@ -0,0 +1,86 @@ +import { extractMentionUserIdsFromPlainBody } from '@hypha-platform/core/client'; + +/** Zero-width space — keeps `@` + display name visually distinct from a raw MXID in the composer. */ +export const MENTION_DISPLAY_ZWSP = '\u200B'; + +/** + * Escape a Hypha/Matrix display label so it cannot break mention token parsing + * (embedded `@` would fragment MXID regexes). + */ +export function sanitizeMentionDisplayLabel(label: string): string { + return label.replace(/@/g, '').trim(); +} + +/** + * Token inserted into the composer after picking a mention: `@` + ZWSP + display name + trailing space. + * Matrix wire format uses raw `@mxid`; {@link replaceDisplayNameMentionsWithMxids} runs before send. + */ +export function formatComposerMentionToken(displayLabel: string): string { + const safe = sanitizeMentionDisplayLabel(displayLabel); + return `@${MENTION_DISPLAY_ZWSP}${safe} `; +} + +/** + * Replace `@` + ZWSP + known display labels with Matrix user IDs for `m.room.message` body. + * Longest label first so "John Smith" wins over "John". + */ +export function replaceDisplayNameMentionsWithMxids( + plain: string, + labelToUserId: ReadonlyMap, +): string { + const labels = [...labelToUserId.keys()].sort((a, b) => b.length - a.length); + let out = ''; + let i = 0; + while (i < plain.length) { + const z = plain.indexOf(MENTION_DISPLAY_ZWSP, i); + if (z === -1) { + out += plain.slice(i); + break; + } + if (z === 0 || plain[z - 1] !== '@') { + out += plain.slice(i, z + 1); + i = z + 1; + continue; + } + + const after = plain.slice(z + 1); + let matchedLabel: string | undefined; + for (const label of labels) { + if (!after.startsWith(label)) continue; + const next = after[label.length]; + if (next !== undefined && !/^[\s.,!?;:\n]/.test(next)) continue; + matchedLabel = label; + break; + } + + if (matchedLabel) { + const uid = labelToUserId.get(matchedLabel); + if (uid) { + out += plain.slice(i, z - 1); + out += uid; + i = z + 1 + matchedLabel.length; + continue; + } + } + + out += plain.slice(i, z + 1); + i = z + 1; + } + return out; +} + +/** + * Matrix wire text + MSC3952 user_ids for send. The composer may use + * display-name tokens; this converts them to `@mxid` and collects mention ids. + */ +export function wireComposerPlainForMatrixSend( + composerPlain: string, + sanitizedLabelToUserId: ReadonlyMap, +): { wirePlain: string; mentionUserIds: string[] } { + const wirePlain = replaceDisplayNameMentionsWithMxids( + composerPlain, + sanitizedLabelToUserId, + ); + const mentionUserIds = extractMentionUserIdsFromPlainBody(wirePlain); + return { wirePlain, mentionUserIds }; +} diff --git a/packages/epics/src/common/human-chat-panel/human-chat-mention-candidate-row.tsx b/packages/epics/src/common/human-chat-panel/human-chat-mention-candidate-row.tsx index 0731cb8784..ccbcc204a0 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-mention-candidate-row.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-mention-candidate-row.tsx @@ -1,24 +1,10 @@ 'use client'; -import { useMemo } from 'react'; -import { - useUserPrivyIdByMatrixId, - usePersonBySub, -} from '@hypha-platform/core/client'; - import { PersonAvatar } from '../../people/components/person-avatar'; import { APP_CHROME_SUBTLE_SQUARE_RADIUS } from '../../spaces/components/compact-space-banner'; import { cn } from '@hypha-platform/ui-utils'; -function formatHyphaPersonName(p: { - name?: string | null; - surname?: string | null; - nickname?: string | null; -}): string { - const full = [p.name, p.surname].filter(Boolean).join(' ').trim(); - if (full) return full; - return p.nickname?.trim() ?? ''; -} +import { useResolvedMentionCandidateLabel } from './use-resolved-mention-candidate-label'; export type MentionCandidateRowProps = { matrixUserId: string; @@ -27,7 +13,13 @@ export type MentionCandidateRowProps = { /** When set (space roster row), fetch Person directly — skips Matrix→Privy link query. */ privySub?: string; isActive: boolean; - onPick: () => void; + /** Legacy: no resolved Hypha name. Prefer {@link onPickResolved}. */ + onPick?: () => void; + /** + * Called with the same display string shown in the row (Hypha Person name when resolved). + * Use this for the composer token so it matches the dropdown. + */ + onPickResolved?: (resolvedDisplayForComposer: string) => void; }; /** @@ -41,25 +33,28 @@ export function HumanChatMentionCandidateRow({ privySub, isActive, onPick, + onPickResolved, }: MentionCandidateRowProps) { - const { privyUserId: linkedSub, isLoading: loadingLink } = - useUserPrivyIdByMatrixId({ - matrixUserId: privySub ? undefined : matrixUserId, - }); - const resolvedSub = privySub ?? linkedSub; - const { person, isLoading: loadingPerson } = usePersonBySub({ - sub: resolvedSub, + const { + resolvedLabel: resolvedName, + busy, + avatarUrl, + pickDisabled, + } = useResolvedMentionCandidateLabel({ + userId: matrixUserId, + displayLabel: matrixFallbackLabel, + privySub, }); - const resolvedName = useMemo(() => { - const fromPerson = person ? formatHyphaPersonName(person) : ''; - return fromPerson || matrixFallbackLabel; - }, [person, matrixFallbackLabel]); + const avatarSrc = avatarUrl?.trim() || matrixFallbackAvatarUrl || undefined; - const avatarSrc = - person?.avatarUrl?.trim() || matrixFallbackAvatarUrl || undefined; - const busy = - (!privySub && loadingLink) || (Boolean(resolvedSub) && loadingPerson); + const handleClick = () => { + if (onPickResolved) { + onPickResolved(resolvedName); + } else { + onPick?.(); + } + }; return ( ); diff --git a/packages/epics/src/common/human-chat-panel/human-chat-mention-token.ts b/packages/epics/src/common/human-chat-panel/human-chat-mention-token.ts index 3880244a03..50d7ef5dfe 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-mention-token.ts +++ b/packages/epics/src/common/human-chat-panel/human-chat-mention-token.ts @@ -2,6 +2,8 @@ * Parse an active `@query` fragment before the cursor for mention autocomplete. */ +import { MENTION_DISPLAY_ZWSP } from './human-chat-display-mention'; + export type ActiveAtToken = { /** Index of `@` in `value`. */ start: number; @@ -32,10 +34,14 @@ export function getActiveAtToken( if (!isAtWordStart(value, atIdx)) return null; const afterAt = before.slice(atIdx + 1); - if (afterAt.includes('\n')) return null; - if (/\s/.test(afterAt)) return null; + /** Composer tokens are `@` + ZWSP + display name — strip ZWSP for query matching. */ + const afterForQuery = afterAt.startsWith(MENTION_DISPLAY_ZWSP) + ? afterAt.slice(MENTION_DISPLAY_ZWSP.length) + : afterAt; + if (afterForQuery.includes('\n')) return null; + if (/\s/.test(afterForQuery)) return null; - const query = afterAt.slice(0, MAX_QUERY_LEN); + const query = afterForQuery.slice(0, MAX_QUERY_LEN); /** Unicode letters / marks / numbers — `\w` is ASCII-only and rejects José, Zoë, etc. */ if (!/^[\p{L}\p{M}\p{N}_.=\-/:']*$/u.test(query)) return null; diff --git a/packages/epics/src/common/human-chat-panel/human-chat-message-link.ts b/packages/epics/src/common/human-chat-panel/human-chat-message-link.ts index e311b4c4d7..df910779e7 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-message-link.ts +++ b/packages/epics/src/common/human-chat-panel/human-chat-message-link.ts @@ -1,29 +1,60 @@ /** * In-app Human Chat deep links for the DHO space route (`/[lang]/dho/[slug]`). - * Query: `?chat=&msg=` — handled by HumanRightPanel. + * Short form: `?msg=` when opened on the same space (current room). + * Legacy: `?chat=&msg=` still supported for cross-room pointers. */ /** Match `/en/dho/my-space/...` — captures locale + space slug. */ const DHO_SPACE_PATH_RE = /^\/([^/]+)\/dho\/([^/]+)/; +/** + * Shareable link to highlight one chat message. Uses **short** query (`msg` only) + * so copied URLs are smaller; HumanRightPanel resolves using the active space room. + */ export function buildHyphaChatMessageUrl( pathname: string, - roomId: string, + _roomId: string, messageId: string, ): string | null { const m = pathname.match(DHO_SPACE_PATH_RE); if (!m) return null; const lang = m[1]; const slug = m[2]; - const qs = `chat=${encodeURIComponent(roomId)}&msg=${encodeURIComponent( - messageId, - )}`; + const qs = `msg=${encodeURIComponent(messageId)}`; if (typeof window === 'undefined') { return `/${lang}/dho/${slug}?${qs}`; } return `${window.location.origin}/${lang}/dho/${slug}?${qs}`; } +/** True for Hypha DHO URLs that point at a specific Matrix message (timeline deep link). */ +export function isHyphaDhoChatMessageUrl(href: string): boolean { + try { + const u = new URL(href); + const hostOk = + u.hostname === 'localhost' || + u.hostname === 'hypha.earth' || + u.hostname.endsWith('.hypha.earth') || + (typeof window !== 'undefined' && u.origin === window.location.origin); + if (!hostOk) return false; + if (!/\/[^/]+\/dho\/[^/]+/.test(u.pathname)) return false; + return u.searchParams.has('msg'); + } catch { + return false; + } +} + +/** Space slug from a Hypha DHO URL path (`/en/dho/treespace/...` → `treespace`). */ +export function hyphaDhoSlugFromUrl(href: string): string | null { + try { + const u = new URL(href); + const m = u.pathname.match(/\/[^/]+\/dho\/([^/]+)/); + return m?.[1] ?? null; + } catch { + return null; + } +} + /** Short label after `#` for Discord-style link preview (space slug from URL). */ export function chatLinkChannelLabelFromPathname( pathname: string, diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-call-banner.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-call-banner.tsx index 9ccee9df17..fac51c0e9a 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-call-banner.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-call-banner.tsx @@ -25,6 +25,9 @@ type HumanChatPanelCallBannerProps = { participantCount: number; /** When >=1, show that others are in the call (not the local user—used for "Y others"). */ othersInRoomCallCount: number; + /** Others appear in Matrix state but remote video/audio feed never attached (WebRTC/signaling). */ + remoteMediaStall?: boolean; + onDismissRemoteMediaStall?: () => void; onLeave: () => void; onToggleMic: () => void; onToggleCamera: () => void; @@ -41,6 +44,8 @@ function errorKey(code: SpaceGroupCallErrorCode): string { return 'callErrorPermission'; case 'NOT_READY': return 'callErrorNotReady'; + case 'CONNECT_STALL': + return 'callErrorConnectStall'; case 'NO_ROOM': return 'callErrorNoRoom'; case 'NO_CLIENT': @@ -69,6 +74,8 @@ export function HumanChatPanelCallBanner({ isLocalVideoMuted, participantCount, othersInRoomCallCount, + remoteMediaStall = false, + onDismissRemoteMediaStall, onLeave, onToggleMic, onToggleCamera, @@ -81,7 +88,9 @@ export function HumanChatPanelCallBanner({ const t = useTranslations('HumanChatPanel'); const showRetryOnError = errorCode != null && - (errorCode === 'WEBRTC_FAILED' || errorCode === 'UNKNOWN'); + (errorCode === 'CONNECT_STALL' || + errorCode === 'WEBRTC_FAILED' || + errorCode === 'UNKNOWN'); if (callState === 'error' && errorCode) { return ( @@ -157,6 +166,25 @@ export function HumanChatPanelCallBanner({ {t('callTabBackgroundHint')}

)} + {remoteMediaStall && callState === 'connected' && ( +
+

+ {t('callRemoteMediaStallHint')} +

+ {onDismissRemoteMediaStall && ( + + )} +
+ )} {screenshareErrorCode && callState === 'connected' && (
void; + onJoinAudio?: () => void; + onJoinVideo: () => void; /** * When set, replaces the “call in progress” line (e.g. “You left the call”). */ @@ -24,13 +24,27 @@ export function HumanChatPanelCallJoinStrip({ deviceCount, disabled, busy, - onJoinCall, + onJoinAudio, + onJoinVideo, durableMessage, onDismissDurable, }: HumanChatPanelCallJoinStripProps) { const t = useTranslations('HumanChatPanel'); const statusLine = t('callJoinStripLine', { count: deviceCount }); const hasDurable = Boolean(durableMessage); + const audioLabel = + deviceCount > 0 + ? t('callJoinWithAudioShort') + : t('callStartWithAudioShort'); + const videoLabel = + deviceCount > 0 + ? t('callJoinWithVideoShort') + : t('callStartWithVideoShort'); + const audioTitle = + deviceCount > 0 ? t('callJoinWithAudio') : t('callStartWithAudio'); + const videoTitle = + deviceCount > 0 ? t('callJoinWithVideo') : t('callStartWithVideo'); + const showAudioButton = deviceCount > 0 || Boolean(onJoinAudio); return (
)} - {!hasDurable && ( + {showAudioButton ? ( - )} + ) : null} +
diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-call-stage.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-call-stage.tsx index a5f8363aca..13b0632e4e 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-call-stage.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-call-stage.tsx @@ -18,8 +18,16 @@ import { import { useTranslations } from 'next-intl'; import { cn } from '@hypha-platform/ui-utils'; import { Loader2, Maximize2, MicOff, User } from 'lucide-react'; -import type { SpaceGroupCallState } from '@hypha-platform/core/client'; -import { matrixMemberDisplayLabel } from './matrix-room-member-display'; +import { + usePersonBySub, + useUserPrivyIdByMatrixId, + type SpaceGroupCallState, +} from '@hypha-platform/core/client'; +import { Skeleton } from '@hypha-platform/ui'; +import { + matrixMemberDisplayLabel, + needsHyphaProfileResolutionForMatrixLabel, +} from './matrix-room-member-display'; import { CallAudioVoiceWaves } from './call-audio-voice-waves'; import type { CallFullViewLayoutMode } from './call-full-view-layout'; import { CallFullViewPaneSplitter } from './human-chat-panel-call-full-view-pane-splitter'; @@ -47,6 +55,8 @@ type HumanChatPanelCallStageBaseProps = { * stage matches the member count (§ Hypha: avoid “2 members, 1 tile”). */ inCallUserIds?: string[] | null; + /** True when remote participant map has users but feeds never attached (show stall copy). */ + remoteMediaStall?: boolean; }; type HumanChatPanelCallStageProps = HumanChatPanelCallStageBaseProps & { @@ -270,6 +280,7 @@ export function HumanChatPanelCallStage({ resolveMemberLabel, currentUserProfileAvatarUrl = null, inCallUserIds = null, + remoteMediaStall = false, layout, onRequestFullView, fullViewOpen = false, @@ -452,6 +463,7 @@ export function HumanChatPanelCallStage({ isFullView={isFull} isPip={false} resolveMemberLabel={resolveMemberLabel} + remoteMediaStall={remoteMediaStall} t={t} /> ); @@ -684,6 +696,7 @@ export function HumanChatPanelCallStage({ isFullView={isFull} isPip={false} resolveMemberLabel={resolveMemberLabel} + remoteMediaStall={remoteMediaStall} t={t} /> ) : null} @@ -856,6 +869,7 @@ export function HumanChatPanelCallStage({ isFullView={isFull} isPip={false} resolveMemberLabel={resolveMemberLabel} + remoteMediaStall={remoteMediaStall} t={t} /> @@ -980,6 +994,41 @@ export function HumanChatPanelCallStage({ ); } +function usePlaceholderParticipantName( + room: Room | null, + userId: string, + resolveMemberLabel: (userId: string | undefined) => string, + fallback: string, +): { text: string; showSkeleton: boolean } { + const syncLabel = useMemo(() => { + const roster = resolveMemberLabel(userId)?.trim(); + if (roster) return roster; + const m = room?.getMember(userId) ?? null; + if (m) return matrixMemberDisplayLabel(m, userId); + return resolveMemberLabel(userId)?.trim() || fallback; + }, [room, userId, resolveMemberLabel, fallback]); + + const needsProfile = needsHyphaResolutionForCallLabel(syncLabel, userId); + const { privyUserId: linkedSub, isLoading: loadingLink } = + useUserPrivyIdByMatrixId({ + matrixUserId: needsProfile ? userId : undefined, + }); + const { person, isLoading: loadingPerson } = usePersonBySub({ + sub: linkedSub, + }); + + const text = useMemo(() => { + const fromPerson = person ? formatHyphaPersonName(person) : ''; + if (fromPerson) return fromPerson; + return syncLabel; + }, [person, syncLabel]); + + const showSkeleton = + needsProfile && (loadingLink || (Boolean(linkedSub) && loadingPerson)); + + return { text, showSkeleton }; +} + function CallParticipantPlaceholderTile({ client, roomId, @@ -989,6 +1038,7 @@ function CallParticipantPlaceholderTile({ isFullView, isPip, resolveMemberLabel, + remoteMediaStall = false, t, }: { client: MatrixClient | null; @@ -999,14 +1049,17 @@ function CallParticipantPlaceholderTile({ isFullView: boolean; isPip: boolean; resolveMemberLabel: (userId: string | undefined) => string; + remoteMediaStall?: boolean; t: (key: string) => string; }) { const room: Room | null = roomId && client ? client.getRoom(roomId) ?? null : null; - const member = room?.getMember(userId) ?? null; - const label = member - ? matrixMemberDisplayLabel(member, userId) - : resolveMemberLabel(userId) || t('callRemoteParticipant'); + const { text: label, showSkeleton } = usePlaceholderParticipantName( + room, + userId, + resolveMemberLabel, + t('callRemoteParticipant'), + ); const px = isPip ? 48 : isFullView && !isPip ? 128 : 80; const avatarUrl = matrixMemberAvatarSquareForCall(client, roomId, userId, px) ?? @@ -1014,6 +1067,10 @@ function CallParticipantPlaceholderTile({ ? currentUserProfileAvatarUrl?.trim() || undefined : undefined); + const statusLine = remoteMediaStall + ? t('callRemoteParticipantMediaStalled') + : t('callConnecting'); + return (
)} -
- -
+ {!remoteMediaStall ? ( +
+ +
+ ) : null}

- {label} + {showSkeleton ? ( + + ) : ( + label + )}

- {t('callConnecting')} + {statusLine}

); } -function displayLabel( +function formatHyphaPersonName(p: { + name?: string | null; + surname?: string | null; + nickname?: string | null; +}): string { + const full = [p.name, p.surname].filter(Boolean).join(' ').trim(); + if (full) return full; + if (p.nickname?.trim()) return p.nickname.trim(); + return ''; +} + +/** Same rule as timeline headers: fetch Hypha Person when Matrix/roster label is still bridged-tech. */ +function needsHyphaResolutionForCallLabel( + profileLabel: string | undefined, + matrixUserId: string | undefined, +): boolean { + if (!matrixUserId?.trim()) return false; + const l = profileLabel?.trim() ?? ''; + if (!l) return true; + if (l === matrixUserId) return true; + return needsHyphaProfileResolutionForMatrixLabel(l); +} + +function useCallParticipantDisplayName( room: Room | null, feed: CallFeed, currentUserId: string | null, resolveMemberLabel: (userId: string | undefined) => string, fallback: string, -): string { - if (feed.isLocal() && currentUserId) { - return resolveMemberLabel(currentUserId); - } - const m = room?.getMember(feed.userId) ?? null; - if (m) return matrixMemberDisplayLabel(m, feed.userId); - return resolveMemberLabel(feed.userId) || fallback; + isPip: boolean, + isShare: boolean, +): { text: string; showSkeleton: boolean } { + const uid = feed.userId; + const isLocalFeed = feed.isLocal(); + + const syncLabel = useMemo(() => { + if (isPip) return ''; // caller uses "You" + if (isLocalFeed && currentUserId) { + return resolveMemberLabel(currentUserId).trim(); + } + /** Roster/Hypha merge first — avoid Privy slug from raw Matrix member displayname. */ + const roster = resolveMemberLabel(uid)?.trim(); + if (roster) return roster; + const m = room?.getMember(uid) ?? null; + if (m) return matrixMemberDisplayLabel(m, uid); + return resolveMemberLabel(uid)?.trim() || fallback; + }, [ + room, + uid, + isLocalFeed, + currentUserId, + resolveMemberLabel, + fallback, + isPip, + isShare, + ]); + + const needsProfile = + !isPip && + !isShare && + !isLocalFeed && + needsHyphaResolutionForCallLabel(syncLabel, uid); + + const { privyUserId: linkedSub, isLoading: loadingLink } = + useUserPrivyIdByMatrixId({ + matrixUserId: needsProfile ? uid : undefined, + }); + const { person, isLoading: loadingPerson } = usePersonBySub({ + sub: linkedSub, + }); + + const text = useMemo(() => { + if (isPip) return ''; // overlay uses callYou + if (isLocalFeed && currentUserId) return syncLabel; + const fromPerson = person ? formatHyphaPersonName(person) : ''; + if (fromPerson) return fromPerson; + return syncLabel; + }, [isPip, isLocalFeed, currentUserId, person, syncLabel]); + + const showSkeleton = + needsProfile && (loadingLink || (Boolean(linkedSub) && loadingPerson)); + + return { text, showSkeleton }; } const CallFeedTile = ({ @@ -1129,27 +1262,14 @@ const CallFeedTile = ({ resolveMemberLabel: (userId: string | undefined) => string; t: (key: string) => string; }) => { - const label = isPip - ? t('callYou') - : isShare - ? displayLabel( - room, - feed, - currentUserId, - resolveMemberLabel, - t('callScreenShare'), - ) - : displayLabel( - room, - feed, - currentUserId, - resolveMemberLabel, - t('callRemoteParticipant'), - ); + const nameFallback = isShare + ? t('callScreenShare') + : t('callRemoteParticipant'); return ( ); @@ -1166,6 +1287,7 @@ const CallFeedTile = ({ const FeedContent = ({ client, roomId, + room, currentUserId, currentUserProfileAvatarUrl, feed, @@ -1173,11 +1295,13 @@ const FeedContent = ({ isPip, isFullView, isActiveSpeaker, - label, + resolveMemberLabel, + nameFallback, t, }: { client: MatrixClient | null; roomId: string | null; + room: Room | null; currentUserId: string | null; currentUserProfileAvatarUrl?: string | null; feed: CallFeed; @@ -1185,13 +1309,29 @@ const FeedContent = ({ isPip: boolean; isFullView: boolean; isActiveSpeaker: boolean; - label: string; + resolveMemberLabel: (userId: string | undefined) => string; + nameFallback: string; t: (key: string) => string; }) => { + const { text: resolvedName, showSkeleton } = useCallParticipantDisplayName( + room, + feed, + currentUserId, + resolveMemberLabel, + nameFallback, + isPip, + isShare, + ); + const overlayLabel = isPip ? t('callYou') : resolvedName; + const ariaLabel = + isShare && !isPip ? nameFallback : isPip ? t('callYou') : resolvedName; + const ref = useRef(null); + const audioRef = useRef(null); const stream = feed.stream; const [, rerenderOnFeed] = useReducer((n: number) => n + 1, 0); + const [streamBindVersion, rebindStream] = useReducer((n: number) => n + 1, 0); useEffect(() => { const el = ref.current; @@ -1206,22 +1346,68 @@ const FeedContent = ({ return () => { el.srcObject = null; }; - }, [stream]); + }, [stream, streamBindVersion]); + + useEffect(() => { + const el = audioRef.current; + if (!el) return; + el.srcObject = stream; + el.play().catch((err) => { + if (process.env.NODE_ENV === 'development') { + console.debug('[CallFeedTile] audio play rejected', err); + } + }); + + return () => { + el.srcObject = null; + }; + }, [stream, streamBindVersion]); useEffect(() => { const onFeedVisualChange = () => { rerenderOnFeed(); }; - feed.on(CallFeedEvent.MuteStateChanged, onFeedVisualChange); - feed.on(CallFeedEvent.NewStream, onFeedVisualChange); + const onFeedMediaChange = () => { + rebindStream(); + rerenderOnFeed(); + }; + feed.on(CallFeedEvent.MuteStateChanged, onFeedMediaChange); + const onFeedStreamChange = () => { + rebindStream(); + rerenderOnFeed(); + }; + feed.on(CallFeedEvent.NewStream, onFeedStreamChange); feed.on(CallFeedEvent.Speaking, onFeedVisualChange); return () => { - feed.removeListener(CallFeedEvent.MuteStateChanged, onFeedVisualChange); - feed.removeListener(CallFeedEvent.NewStream, onFeedVisualChange); + feed.removeListener(CallFeedEvent.MuteStateChanged, onFeedMediaChange); + feed.removeListener(CallFeedEvent.NewStream, onFeedStreamChange); feed.removeListener(CallFeedEvent.Speaking, onFeedVisualChange); }; }, [feed]); + useEffect(() => { + const onTrackChange = () => { + rebindStream(); + rerenderOnFeed(); + }; + stream.addEventListener('addtrack', onTrackChange); + stream.addEventListener('removetrack', onTrackChange); + for (const track of stream.getTracks()) { + track.addEventListener('mute', onTrackChange); + track.addEventListener('unmute', onTrackChange); + track.addEventListener('ended', onTrackChange); + } + return () => { + stream.removeEventListener('addtrack', onTrackChange); + stream.removeEventListener('removetrack', onTrackChange); + for (const track of stream.getTracks()) { + track.removeEventListener('mute', onTrackChange); + track.removeEventListener('unmute', onTrackChange); + track.removeEventListener('ended', onTrackChange); + } + }; + }, [stream]); + const hasVideo = !feed.isVideoMuted() && stream.getVideoTracks().length > 0; /** Analyse mic/remote line whenever the tile has a live audio track (not just Matrix `isSpeaking`, which lags and hid real levels). */ const canVoiceWave = @@ -1290,10 +1476,10 @@ const FeedContent = ({ )} autoPlay playsInline - muted={feed.isLocal()} - aria-label={label} + muted + aria-label={ariaLabel} /> - {feed.isAudioMuted() && ( + {feed.isAudioMuted() && !(isFullView && !isPip) && (
- {label} + {showSkeleton ? ( + + ) : ( + overlayLabel + )}
)} @@ -1336,7 +1526,7 @@ const FeedContent = ({ : 'h-full min-h-[10rem] flex-1', // panel: fill grid cell to match video tile isPip && 'gap-1.5 p-2', )} - aria-label={label} + aria-label={ariaLabel} >
- {label} - {feed.isAudioMuted() ? ` · ${t('callParticipantMuted')}` : null} + {showSkeleton ? ( + + ) : ( + <> + {overlayLabel} + {feed.isAudioMuted() && !(isFullView && !isPip) + ? ` · ${t('callParticipantMuted')}` + : null} + + )}

)} + {!feed.isLocal() && !isShare ? ( +