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 ? (
+
+ ) : null}
);
};
diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx
index c9e93fde06..4054051b58 100644
--- a/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx
+++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx
@@ -62,6 +62,8 @@ import {
useDraftVoiceDuration,
} from './human-chat-panel-voice-audio-row';
import { HumanChatMentionCandidateRow } from './human-chat-mention-candidate-row';
+import { formatComposerMentionToken } from './human-chat-display-mention';
+import { useResolvedMentionCandidateLabel } from './use-resolved-mention-candidate-label';
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
@@ -180,6 +182,19 @@ type HumanChatPanelChatBarProps = {
* Falls back to `mentionCandidates.length > 0` when omitted.
*/
mentionPickerEnabled?: boolean;
+ /**
+ * When the user picks a mention, Hypha-resolved names can differ from `mentionCandidates[].displayLabel`
+ * (Matrix fallback is often a shortened MXID). Merge the chosen display string so send + timeline pills match.
+ */
+ onMergeMentionDisplayLabel?: (userId: string, displayLabel: string) => void;
+ /**
+ * When multiple members sanitize to the same mention key, append a disambiguator so wire send resolves
+ * to the correct MXID (must match {@link HumanRightPanel}'s `mentionSanitizedLabelToUserId`).
+ */
+ getMentionComposerLabel?: (
+ member: ChatMentionCandidate,
+ resolvedComposerLabel?: string,
+ ) => string;
};
/** Blinking REC dot (“on-air”) for active voice recording / dictation controls. */
@@ -460,6 +475,8 @@ export function HumanChatPanelChatBar({
onDraftAttachmentsChange,
mentionCandidates = [],
mentionPickerEnabled,
+ onMergeMentionDisplayLabel,
+ getMentionComposerLabel,
}: HumanChatPanelChatBarProps) {
const t = useTranslations('HumanChatPanel');
@@ -520,6 +537,17 @@ export function HumanChatPanelChatBar({
const [atActive, setAtActive] = useState(0);
const atTokenRef = useRef>(null);
+ const activeAtPick =
+ atOpen && atSuggestions.length > 0
+ ? atSuggestions[
+ Math.max(0, Math.min(atActive, atSuggestions.length - 1))
+ ] ?? null
+ : null;
+ const {
+ resolvedLabel: keyboardResolvedPickLabel,
+ pickDisabled: keyboardPickDisabled,
+ } = useResolvedMentionCandidateLabel(activeAtPick);
+
const [selectionBar, setSelectionBar] = useState<{
top: number;
left: number;
@@ -741,11 +769,21 @@ export function HumanChatPanelChatBar({
);
const applyAtChoice = useCallback(
- (member: ChatMentionCandidate) => {
+ (
+ member: ChatMentionCandidate,
+ /** Same string as the picker row / Hypha Person resolution — overrides Matrix fallback label */
+ resolvedComposerLabel?: string,
+ ) => {
const el = textareaRef.current;
const tok = atTokenRef.current;
if (!el || !tok) return;
- const insertion = `${member.userId} `;
+ const labelForMerge =
+ resolvedComposerLabel?.trim() || member.displayLabel;
+ const labelForToken =
+ getMentionComposerLabel?.(member, resolvedComposerLabel) ??
+ labelForMerge;
+ const insertion = formatComposerMentionToken(labelForToken);
+ onMergeMentionDisplayLabel?.(member.userId, labelForMerge);
const start = tok.start;
const end = el.selectionStart ?? value.length;
const { next, caret } = insertAtCaret(value, start, end, insertion);
@@ -759,7 +797,13 @@ export function HumanChatPanelChatBar({
autoResize();
});
},
- [value, onChange, autoResize],
+ [
+ value,
+ onChange,
+ autoResize,
+ onMergeMentionDisplayLabel,
+ getMentionComposerLabel,
+ ],
);
const openMentionPicker = useCallback(() => {
@@ -1318,12 +1362,19 @@ export function HumanChatPanelChatBar({
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
+ if (keyboardPickDisabled) return;
const safeIndex = Math.max(
0,
Math.min(atActive, atSuggestions.length - 1),
);
const pick = atSuggestions[safeIndex];
- if (pick) applyAtChoice(pick);
+ if (pick)
+ applyAtChoice(
+ pick,
+ keyboardResolvedPickLabel.trim()
+ ? keyboardResolvedPickLabel
+ : undefined,
+ );
return;
}
if (e.key === 'Escape') {
@@ -1380,6 +1431,8 @@ export function HumanChatPanelChatBar({
atSuggestions,
atActive,
applyAtChoice,
+ keyboardResolvedPickLabel,
+ keyboardPickDisabled,
colonOpen,
colonSuggestions,
colonActive,
@@ -1817,7 +1870,7 @@ export function HumanChatPanelChatBar({
matrixFallbackAvatarUrl={m.avatarUrl}
privySub={m.privySub}
isActive={idx === atActive}
- onPick={() => applyAtChoice(m)}
+ onPickResolved={(resolved) => applyAtChoice(m, resolved)}
/>
))}
diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-in-call-controls.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-in-call-controls.tsx
index e2b2ca3a59..16e74e5ced 100644
--- a/packages/epics/src/common/human-chat-panel/human-chat-panel-in-call-controls.tsx
+++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-in-call-controls.tsx
@@ -18,7 +18,6 @@ import {
type HumanChatPanelInCallControlsProps = {
callState: SpaceGroupCallState;
- callKind: 'audio' | 'video' | null;
isMicrophoneMuted: boolean;
isLocalVideoMuted: boolean;
isScreensharing: boolean;
@@ -36,7 +35,6 @@ type HumanChatPanelInCallControlsProps = {
*/
export function HumanChatPanelInCallControls({
callState,
- callKind,
isMicrophoneMuted,
isLocalVideoMuted,
isScreensharing,
@@ -57,7 +55,9 @@ export function HumanChatPanelInCallControls({
const baseBtn = isFull
? 'h-10 min-w-10 sm:h-11 sm:min-w-11 inline-flex items-center justify-center rounded-full border border-zinc-600/80 bg-zinc-900/90 px-2.5 text-white shadow-sm backdrop-blur-sm transition-colors hover:bg-zinc-800/95 focus-visible:outline focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50'
: 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-background/95 text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline focus-visible:ring-2 focus-visible:ring-ring';
- const neutralBtn = isFull ? baseBtn : 'bg-background text-foreground';
+ const neutralBtn = isFull
+ ? baseBtn
+ : 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-background text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline focus-visible:ring-2 focus-visible:ring-ring';
const leaveIcon = isFull ? fullViewIcon : 'h-4 w-4';
/**
* End call — classic “hang up” red (explicit red-600/700, not `destructive` token
@@ -116,38 +116,36 @@ export function HumanChatPanelInCallControls({
)}
- {callKind === 'video' && (
-
- )}
+ : baseBtn
+ : isLocalVideoMuted
+ ? camOffBtn
+ : neutralBtn,
+ (isFull || isLocalVideoMuted) &&
+ 'inline-flex items-center justify-center',
+ 'disabled:cursor-not-allowed',
+ !isFull && controlsDisabled && 'opacity-50',
+ )}
+ title={t('callControlsCamera')}
+ aria-label={
+ isLocalVideoMuted
+ ? t('callControlsCameraOffAria')
+ : t('callControlsCameraOnAria')
+ }
+ >
+ {isLocalVideoMuted ? (
+
+ ) : (
+
+ )}
+