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]/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/package.json b/package.json
index 34e4cb3550..2906fef039 100644
--- a/package.json
+++ b/package.json
@@ -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",
+ "dompurify": ">=3.4.0",
+ "hono": ">=4.12.12"
+ }
}
}
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..03d902e580
--- /dev/null
+++ b/packages/core/src/matrix/client/hooks/group-call-webrtc-diagnostics.ts
@@ -0,0 +1,239 @@
+'use client';
+
+import type { MatrixClient } from 'matrix-js-sdk';
+import {
+ GroupCallStatsReportEvent,
+ type GroupCall,
+} from 'matrix-js-sdk/lib/webrtc/groupCall';
+import type { SummaryStatsReport } from 'matrix-js-sdk/lib/webrtc/stats/statsReport';
+import { logSpaceGroupCallEvent } from './space-group-call-telemetry';
+
+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: SummaryStatsReport }) => {
+ 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..a96e7b201e 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
@@ -8,12 +8,54 @@ 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';
roomId: string;
kind?: 'audio' | 'video';
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(
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..003950ec83 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
@@ -7,6 +7,11 @@ import { GroupCallEventHandlerEvent } from 'matrix-js-sdk/lib/webrtc/groupCallEv
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';
@@ -22,6 +27,18 @@ export type SpaceGroupCallErrorCode =
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 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 +104,25 @@ export function useSpaceGroupCall(roomId: string | null) {
const lastRoomIdForTelemetryRef = useRef(null);
const activeGroupCallRoomIdRef = useRef(null);
const loggedStatsForGroupCallIdRef = useRef(null);
+ const webRtcDiagCleanupRef = useRef<(() => void) | null>(null);
+ /** 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,7 +150,29 @@ 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();
+ webRtcDiagCleanupRef.current?.();
+ webRtcDiagCleanupRef.current = null;
+ remoteMediaGapSinceRef.current = null;
+ remoteMediaStallLoggedRef.current = false;
+ remoteMediaStallBannerDismissedRef.current = false;
+ setRemoteMediaStall(false);
if (feedUpdateRafRef.current != null) {
cancelAnimationFrame(feedUpdateRafRef.current);
feedUpdateRafRef.current = null;
@@ -161,7 +219,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,6 +276,90 @@ 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) {
+ 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) => {
const onError = (err: unknown) => {
@@ -258,10 +400,12 @@ export function useSpaceGroupCall(roomId: string | null) {
});
gc.on(GroupCallEvent.ParticipantsChanged, () => {
updateParticipantCount();
+ evalRemoteMediaStall();
});
const onFeedsMaybeParticipants = () => {
scheduleFeedBatched();
updateParticipantCount();
+ evalRemoteMediaStall();
};
gc.on(GroupCallEvent.UserMediaFeedsChanged, onFeedsMaybeParticipants);
gc.on(GroupCallEvent.ScreenshareFeedsChanged, onFeedsMaybeParticipants);
@@ -288,6 +432,7 @@ export function useSpaceGroupCall(roomId: string | null) {
runCleanup,
scheduleFeedBatched,
updateParticipantCount,
+ evalRemoteMediaStall,
],
);
@@ -425,9 +570,37 @@ export function useSpaceGroupCall(roomId: string | null) {
updateParticipantCount();
setCallState('connecting');
+ clearConnectingStallTimer();
+ connectingStallTimerRef.current = setTimeout(() => {
+ clearConnectingStallTimer();
+ 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('UNKNOWN');
+ 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();
isJoiningRef.current = false;
const permissionLike = isPermissionLikeGroupCallError(e);
if (permissionLike) {
@@ -451,6 +624,20 @@ export function useSpaceGroupCall(roomId: string | null) {
return;
}
+ clearConnectingStallTimer();
+
+ webRtcDiagCleanupRef.current?.();
+ webRtcDiagCleanupRef.current = null;
+ if (GROUP_WEBRTC_SUMMARY_STATS_MS > 0) {
+ webRtcDiagCleanupRef.current = attachGroupCallWebRtcDiagnostics({
+ gc,
+ roomId,
+ summaryStatsIntervalMs: GROUP_WEBRTC_SUMMARY_STATS_MS,
+ });
+ }
+
+ void probeMatrixTurnServerReadiness({ client, roomId, kind });
+
setCallState('connected');
refreshLocalPreview();
updateParticipantCount();
@@ -459,6 +646,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) {
@@ -497,6 +696,8 @@ export function useSpaceGroupCall(roomId: string | null) {
runCleanup,
setActiveKeyFromGroupCall,
updateParticipantCount,
+ logDevMediaSnapshot,
+ evalRemoteMediaStall,
],
);
@@ -626,12 +827,35 @@ export function useSpaceGroupCall(roomId: string | null) {
if (!room) return;
const bump = () => {
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;
@@ -828,6 +1052,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-webrtc-env.ts b/packages/core/src/matrix/client/matrix-webrtc-env.ts
new file mode 100644
index 0000000000..4949200744
--- /dev/null
+++ b/packages/core/src/matrix/client/matrix-webrtc-env.ts
@@ -0,0 +1,62 @@
+/**
+ * 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);
+}
+
+/**
+ * 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 parseNonNegativeInt(
+ process.env['NEXT_PUBLIC_MATRIX_WEBRTC_ICE_POOL_SIZE'],
+ 0,
+ );
+}
+
+/**
+ * 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..2ef247a84c 100644
--- a/packages/core/src/matrix/client/providers/matrix-provider.tsx
+++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx
@@ -27,6 +27,11 @@ import {
mergeMatrixMentionsIntoContent,
resolveMentionUserIdsForSend,
} from '../../mentions';
+import {
+ matrixWebRtcFallbackIceAllowedFromEnv,
+ matrixWebRtcForceTurnFromEnv,
+ matrixWebRtcIceCandidatePoolSizeFromEnv,
+} from '../matrix-webrtc-env';
export interface SendAttachmentInput {
file: File;
@@ -396,9 +401,9 @@ export const MatrixProvider: React.FC = ({ children }) => {
disableVoip: false,
useE2eForGroupCall: true,
useLivekitForGroupCalls: false,
- forceTURN: false,
- fallbackICEServerAllowed: false,
- iceCandidatePoolSize: 0,
+ forceTURN: matrixWebRtcForceTurnFromEnv(),
+ fallbackICEServerAllowed: matrixWebRtcFallbackIceAllowedFromEnv(),
+ iceCandidatePoolSize: matrixWebRtcIceCandidatePoolSizeFromEnv(),
});
await matrixClient.startClient();
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 (
ev.preventDefault()}
- onClick={onPick}
+ onClick={handleClick}
>
-
-
- {resolvedName}
-
-
- {matrixUserId}
-
+
+ {resolvedName}
);
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..ff2576df62 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,57 @@
/**
* 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.endsWith('hypha.earth');
+ 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..b064af4e95 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;
@@ -69,6 +72,8 @@ export function HumanChatPanelCallBanner({
isLocalVideoMuted,
participantCount,
othersInRoomCallCount,
+ remoteMediaStall = false,
+ onDismissRemoteMediaStall,
onLeave,
onToggleMic,
onToggleCamera,
@@ -157,6 +162,25 @@ export function HumanChatPanelCallBanner({
{t('callTabBackgroundHint')}
)}
+ {remoteMediaStall && callState === 'connected' && (
+
+
+ {t('callRemoteMediaStallHint')}
+
+ {onDismissRemoteMediaStall && (
+
+ {t('callLeftBannerDismiss')}
+
+ )}
+
+ )}
{screenshareErrorCode && callState === 'connected' && (
);
@@ -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,9 +1309,23 @@ 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 stream = feed.stream;
@@ -1291,7 +1429,7 @@ const FeedContent = ({
autoPlay
playsInline
muted={feed.isLocal()}
- aria-label={label}
+ aria-label={ariaLabel}
/>
{feed.isAudioMuted() && (
- {label}
+ {showSkeleton ? (
+
+ ) : (
+ overlayLabel
+ )}
)}
>
@@ -1336,7 +1478,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() ? ` · ${t('callParticipantMuted')}` : null}
+ >
+ )}
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-mention-inbox.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-mention-inbox.tsx
index 0b9f5b4cd4..ff9a1a8bd4 100644
--- a/packages/epics/src/common/human-chat-panel/human-chat-panel-mention-inbox.tsx
+++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-mention-inbox.tsx
@@ -1,9 +1,11 @@
'use client';
+import { useMemo } from 'react';
import type { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { EventType } from 'matrix-js-sdk';
import { Bell, BellOff } from 'lucide-react';
import { useFormatter, useTranslations } from 'next-intl';
+import { Skeleton } from '@hypha-platform/ui';
import { cn } from '@hypha-platform/ui-utils';
import {
@@ -11,14 +13,22 @@ import {
isRedactedRoomMessageEvent,
contentMentionsMatrixUser,
stripMatrixReplyFallback,
+ usePersonBySub,
+ useUserPrivyIdByMatrixId,
} from '@hypha-platform/core/client';
+import { renderTextWithMentions } from './human-chat-panel-message-bubble';
+import { needsHyphaProfileResolutionForMatrixLabel } from './matrix-room-member-display';
+import { gatherAggregatedMentionPreviews } from './matrix-chat-unread';
export type HumanChatPanelMentionTabProps = {
client: MatrixClient | null;
roomId: string | null;
currentUserId: string | null;
resolveMemberLabel: (matrixUserId: string) => string;
- onSelectMessage: (eventId: string) => void;
+ /** `roomId` omitted = current space chat room. */
+ onSelectMessage: (eventId: string, fromRoomId?: string) => void;
+ /** When true, list @-mentions from all joined Matrix rooms with room labels. */
+ aggregatedMentions?: boolean;
};
function excerptFromRoomMessage(ev: MatrixEvent): string {
@@ -27,6 +37,60 @@ function excerptFromRoomMessage(ev: MatrixEvent): string {
return stripMatrixReplyFallback(raw).trim().slice(0, 280);
}
+function formatPersonDisplayName(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 '';
+}
+
+/**
+ * Sender line in @ inbox: match chat timeline — roster label first, then Hypha Person
+ * when Matrix still exposes bridged Privy technical display names.
+ */
+function MentionInboxSenderName({
+ matrixUserId,
+ syncLabel,
+}: {
+ matrixUserId: string;
+ syncLabel: string;
+}) {
+ const needs = needsHyphaProfileResolutionForMatrixLabel(syncLabel);
+ const { privyUserId: linkedSub, isLoading: loadingLink } =
+ useUserPrivyIdByMatrixId({
+ matrixUserId: needs ? matrixUserId : undefined,
+ });
+ const { person, isLoading: loadingPerson } = usePersonBySub({
+ sub: linkedSub,
+ });
+
+ const text = useMemo(() => {
+ const fromPerson = person ? formatPersonDisplayName(person) : '';
+ if (fromPerson.trim()) return fromPerson;
+ return syncLabel.trim() || matrixUserId;
+ }, [person, syncLabel, matrixUserId]);
+
+ const loading =
+ needs && (loadingLink || (Boolean(linkedSub) && loadingPerson));
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return {text} ;
+}
+
function gatherMentionEvents(
client: MatrixClient,
roomId: string,
@@ -61,59 +125,122 @@ export function HumanChatPanelMentionTab({
currentUserId,
resolveMemberLabel,
onSelectMessage,
+ aggregatedMentions = false,
}: HumanChatPanelMentionTabProps) {
const t = useTranslations('HumanChatPanel');
const format = useFormatter();
- const rows =
- client && roomId && currentUserId
+ const aggregatedRows =
+ aggregatedMentions && client && currentUserId
+ ? gatherAggregatedMentionPreviews(client, currentUserId, 80)
+ : [];
+
+ const singleRoomRows =
+ !aggregatedMentions && client && roomId && currentUserId
? gatherMentionEvents(client, roomId, currentUserId, 80)
: [];
+ const rows = aggregatedMentions ? aggregatedRows : singleRoomRows;
+
return (
- {rows.length === 0 ? (
+ {(aggregatedMentions
+ ? aggregatedRows.length
+ : singleRoomRows.length) === 0 ? (
{t('mentionInboxEmpty')}
) : (
- {rows.map((ev) => {
- const id = ev.getId();
- const senderId = ev.getSender();
- if (!id || !senderId) return null;
- const excerpt = excerptFromRoomMessage(ev);
- const senderLabel = resolveMemberLabel(senderId);
- const ts = ev.getTs();
-
- return (
-
- onSelectMessage(id)}
- >
-
-
- {senderLabel}
-
-
- {format.dateTime(new Date(ts), {
- hour: 'numeric',
- minute: '2-digit',
- month: 'short',
- day: 'numeric',
- })}
-
-
-
- {excerpt || t('mentionInboxNoPreview')}
-
-
-
- );
- })}
+ {aggregatedMentions
+ ? aggregatedRows.map((row) => {
+ const senderSyncLabel = resolveMemberLabel(row.senderId);
+ return (
+
+ onSelectMessage(row.eventId, row.roomId)}
+ >
+
+
+ {row.roomDisplayName}
+
+
+ {format.dateTime(new Date(row.timestamp), {
+ hour: 'numeric',
+ minute: '2-digit',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+
+
+
+
+
+ {row.excerpt
+ ? renderTextWithMentions(
+ row.excerpt,
+ resolveMemberLabel,
+ false,
+ )
+ : t('mentionInboxNoPreview')}
+
+
+
+ );
+ })
+ : singleRoomRows.map((ev) => {
+ const id = ev.getId();
+ const senderId = ev.getSender();
+ if (!id || !senderId) return null;
+ const excerpt = excerptFromRoomMessage(ev);
+ const senderSyncLabel = resolveMemberLabel(senderId);
+ const ts = ev.getTs();
+
+ return (
+
+ onSelectMessage(id)}
+ >
+
+
+
+
+
+ {format.dateTime(new Date(ts), {
+ hour: 'numeric',
+ minute: '2-digit',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+ {excerpt
+ ? renderTextWithMentions(
+ excerpt,
+ resolveMemberLabel,
+ false,
+ )
+ : t('mentionInboxNoPreview')}
+
+
+
+ );
+ })}
)}
diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx
index f51ab5b614..c29d5dcb1a 100644
--- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx
+++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx
@@ -38,6 +38,10 @@ import {
HumanChatPanelMessageOverflow,
pushRecentChatReaction,
} from './human-chat-panel-message-overflow';
+import {
+ hyphaDhoSlugFromUrl,
+ isHyphaDhoChatMessageUrl,
+} from './human-chat-message-link';
import { ChatMessageRichText } from './parse-simple-matrix-html';
import {
matrixMemberDisplayLabelFromRoom,
@@ -858,150 +862,6 @@ function formatTimestamp(
return t('timestampDate', { date: dateStr, time: timeStr });
}
-/**
- * Split plaintext into runs of literal text vs full Matrix MXIDs (`@local:homeserver`).
- * A naive `@(\w+)…` regex breaks on the colon inside bridged Privy locals — root cause of ugly pills.
- */
-function splitPlainTextMatrixMentions(
- text: string,
-): Array<{ kind: 'text'; value: string } | { kind: 'mxid'; full: string }> {
- const out: Array<
- { kind: 'text'; value: string } | { kind: 'mxid'; full: string }
- > = [];
- const re = new RegExp(MATRIX_MXID_IN_PLAIN_TEXT.source, 'g');
- let lastIndex = 0;
- let m: RegExpExecArray | null;
- while ((m = re.exec(text)) !== null) {
- const mid = normalizePlainTextMxidCaptureFromMatch(
- m[1] ?? '',
- text,
- m.index,
- m[0].length,
- );
- if (!mid) continue;
- const full = `@${mid}`;
- if (m.index > lastIndex) {
- out.push({ kind: 'text', value: text.slice(lastIndex, m.index) });
- }
- out.push({ kind: 'mxid', full });
- // `full` can be shorter than m[0] when normalizePlainTextMxidCaptureFromMatch strips
- // a sentence-punctuation colon; the regex still advances to m.index + m[0].length, while
- // the following text slice uses lastIndex so the stripped `:` remains in a plain 'text' run.
- lastIndex = m.index + full.length;
- }
- if (lastIndex < text.length) {
- out.push({ kind: 'text', value: text.slice(lastIndex) });
- }
- return out;
-}
-
-/**
- * Discord-like mention chip: translucent **accent-9** (same hue as primary buttons `bg-accent-9`),
- * inset ring — not a flat “notification” blue box.
- */
-function chatMentionPillClass(onViewerMentionTintRow: boolean): string {
- return cn(
- /* `min-h` + vertical centering so the chip fills the message line box (not only the glyph bbox). */
- 'inline-flex min-h-[1.35em] w-fit max-w-full min-w-0 items-center self-baseline rounded-md px-[5px] py-0.5 text-[13px] font-semibold leading-snug tracking-tight',
- /* Foreground text keeps contrast on any space-derived accent tint (lime, etc.). */
- onViewerMentionTintRow
- ? 'bg-accent-9/22 text-foreground ring-1 ring-inset ring-accent-9/45 dark:bg-accent-9/28 dark:text-foreground dark:ring-accent-10/50'
- : 'bg-muted/70 text-foreground ring-1 ring-inset ring-accent-9/35 dark:bg-muted/50 dark:text-foreground dark:ring-accent-10/40',
- );
-}
-
-/**
- * Render plaintext with Matrix MXIDs as pills showing room display names (`resolveMx`).
- * Non‑MXID `@handles` keep optional Discord-style pills (no colon in capture).
- */
-function renderTextWithMentions(
- text: string,
- resolveMx: (matrixUserId: string) => string,
- viewerMentionTintRow = false,
-): React.ReactNode[] {
- const segments = splitPlainTextMatrixMentions(text);
- /**
- * Discord-style `@handle` pills — require a left boundary so `jane@example.com`
- * does not render `@example` as a pill.
- */
- const localHandleRe = /(^|[^\w.+-])@([^\s@]{1,100}?)(?=\s|$|[.,!?;:])/g;
-
- const mapPlainFragment = (
- fragment: string,
- keyBase: string,
- ): React.ReactNode[] => {
- const chunks: React.ReactNode[] = [];
- let last = 0;
- let mh: RegExpExecArray | null;
- const reLocal = new RegExp(localHandleRe.source, localHandleRe.flags);
- let keyN = 0;
- while ((mh = reLocal.exec(fragment)) !== null) {
- const prefix = mh[1] ?? '';
- const handle = mh[2]?.trim() ?? '';
- const mentionStart = mh.index + prefix.length;
- if (mentionStart > last) {
- chunks.push(
-
- {fragment.slice(last, mentionStart)}
- ,
- );
- }
- chunks.push(
-
- @{handle}
- ,
- );
- last = mentionStart + handle.length + 1;
- }
- if (last < fragment.length) {
- chunks.push(
-
{fragment.slice(last)} ,
- );
- }
- if (chunks.length === 0 && fragment) {
- return [
{fragment} ];
- }
- return chunks;
- };
-
- const parts: React.ReactNode[] = [];
- let segIdx = 0;
- for (const seg of segments) {
- if (seg.kind === 'mxid') {
- const label = resolveMx(seg.full).trim();
- const displayLabel = label
- ? label.startsWith('@')
- ? label
- : `@${label}`
- : seg.full;
- parts.push(
-
- {displayLabel}
- ,
- );
- } else if (seg.value) {
- parts.push(...mapPlainFragment(seg.value, `seg-${segIdx}`));
- }
- segIdx += 1;
- }
- return parts;
-}
-
-function resolveMatrixPlainAndHtmlFragments(
- fragment: string,
- resolveMx: (matrixUserId: string) => string,
- viewerMentionTintRow = false,
-): React.ReactNode[] {
- return renderTextWithMentions(fragment, resolveMx, viewerMentionTintRow);
-}
-
type ReplyConnectorGeometry = {
width: number;
height: number;
@@ -1208,6 +1068,339 @@ function formatPersonDisplayName(p: {
return '';
}
+/**
+ * Split plaintext into runs of literal text vs full Matrix MXIDs (`@local:homeserver`).
+ * A naive `@(\w+)…` regex breaks on the colon inside bridged Privy locals — root cause of ugly pills.
+ */
+function splitPlainTextMatrixMentions(
+ text: string,
+): Array<{ kind: 'text'; value: string } | { kind: 'mxid'; full: string }> {
+ const out: Array<
+ { kind: 'text'; value: string } | { kind: 'mxid'; full: string }
+ > = [];
+ const re = new RegExp(MATRIX_MXID_IN_PLAIN_TEXT.source, 'g');
+ let lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(text)) !== null) {
+ const mid = normalizePlainTextMxidCaptureFromMatch(
+ m[1] ?? '',
+ text,
+ m.index,
+ m[0].length,
+ );
+ if (!mid) continue;
+ const full = `@${mid}`;
+ if (m.index > lastIndex) {
+ out.push({ kind: 'text', value: text.slice(lastIndex, m.index) });
+ }
+ out.push({ kind: 'mxid', full });
+ lastIndex = m.index + full.length;
+ }
+ if (lastIndex < text.length) {
+ out.push({ kind: 'text', value: text.slice(lastIndex) });
+ }
+ return out;
+}
+
+/**
+ * Discord-like mention: soft fill, **no ring**, minimal horizontal padding so the box hugs `@name`.
+ */
+function chatMentionPillClass(onViewerMentionTintRow: boolean): string {
+ return cn(
+ 'inline-flex min-h-[1.35em] w-fit max-w-full min-w-0 items-center self-baseline rounded px-1 py-0 text-[13px] font-semibold leading-snug tracking-tight',
+ onViewerMentionTintRow
+ ? 'bg-accent-9/22 text-foreground dark:bg-accent-9/28 dark:text-foreground'
+ : 'bg-muted/70 text-foreground dark:bg-muted/50 dark:text-foreground',
+ );
+}
+
+/** Characters often pasted immediately after a URL (not part of the href). */
+const TRAILING_URL_PUNCT_CHARS = new Set('.,;:!?)]}');
+
+/** Strip trailing punctuation — linear scan (avoid polynomial regex on user text). */
+function trimTrailingUrlPunctuation(raw: string): string {
+ let end = raw.length;
+ while (end > 0) {
+ const ch = raw[end - 1];
+ if (ch === undefined || !TRAILING_URL_PUNCT_CHARS.has(ch)) break;
+ end--;
+ }
+ return raw.slice(0, end);
+}
+
+function nextHttpSchemeIndex(text: string, from: number): number {
+ const httpIdx = text.indexOf('http://', from);
+ const httpsIdx = text.indexOf('https://', from);
+ const hi = httpsIdx >= 0 ? httpsIdx : Number.POSITIVE_INFINITY;
+ const lo = httpIdx >= 0 ? httpIdx : Number.POSITIVE_INFINITY;
+ const start = Math.min(lo, hi);
+ return Number.isFinite(start) ? start : -1;
+}
+
+type PlainUrlPiece =
+ | { kind: 'text'; value: string }
+ | { kind: 'url'; href: string; trailing: string };
+
+function splitPlainTextUrls(text: string): PlainUrlPiece[] {
+ const out: PlainUrlPiece[] = [];
+ let last = 0;
+ const n = text.length;
+ while (last < n) {
+ const start = nextHttpSchemeIndex(text, last);
+ if (start < 0) {
+ out.push({ kind: 'text', value: text.slice(last) });
+ break;
+ }
+ if (start > last) {
+ out.push({ kind: 'text', value: text.slice(last, start) });
+ }
+ const schemeLen = text.startsWith('https://', start) ? 8 : 7;
+ let end = start + schemeLen;
+ while (end < n) {
+ const ch = text[end];
+ if (
+ ch === ' ' ||
+ ch === '\t' ||
+ ch === '\n' ||
+ ch === '\r' ||
+ ch === '<' ||
+ ch === '>' ||
+ ch === '"' ||
+ ch === "'"
+ ) {
+ break;
+ }
+ end++;
+ }
+ const raw = text.slice(start, end);
+ const href = trimTrailingUrlPunctuation(raw);
+ const trailing = raw.slice(href.length);
+ out.push({ kind: 'url', href, trailing });
+ last = start + raw.length;
+ }
+ return out.length > 0 ? out : [{ kind: 'text', value: text }];
+}
+
+const chatBodyLinkClass =
+ 'break-all font-medium text-primary underline decoration-primary/35 underline-offset-2 hover:decoration-primary/70';
+
+/** Discord-style compact chip for Hypha chat deep links (short `?msg=` URLs). */
+const chatDeepLinkPillClass =
+ 'inline-flex max-w-[min(100%,18rem)] min-w-0 items-center gap-1 self-baseline truncate rounded-md bg-indigo-100 px-2 py-0.5 text-[13px] font-semibold leading-snug text-indigo-950 ring-1 ring-inset ring-indigo-300/40 dark:bg-indigo-950/50 dark:text-indigo-50 dark:ring-indigo-400/25';
+
+function shortMatrixEventLabel(id: string): string {
+ const t = id.trim();
+ if (!t) return '';
+ if (t.startsWith('$') && t.length > 14) return `${t.slice(0, 10)}…`;
+ return t.length > 16 ? `${t.slice(0, 12)}…` : t;
+}
+
+function renderPlainUrlLink(href: string, key: string): ReactNode {
+ const safe = href.trim();
+ if (!safe) return null;
+ if (isHyphaDhoChatMessageUrl(safe)) {
+ const slug = hyphaDhoSlugFromUrl(safe) ?? 'chat';
+ let msgShort = '';
+ try {
+ const msg = new URL(safe).searchParams.get('msg');
+ if (msg) msgShort = shortMatrixEventLabel(msg);
+ } catch {
+ // ignore
+ }
+ const label = msgShort ? `# ${slug} · ${msgShort}` : `# ${slug}`;
+ return (
+
+ {label}
+
+ );
+ }
+ return (
+
+ {safe}
+
+ );
+}
+
+/**
+ * `@mxid` pill in message body: sync label from room/roster may be a technical bridged ID after reload.
+ * Mirror sender-row logic — resolve Hypha Person via matrix_user_links when the label still looks synthetic.
+ */
+function MxidMentionPill({
+ fullMxid,
+ syncLabel,
+ viewerMentionTintRow,
+}: {
+ fullMxid: string;
+ syncLabel: string;
+ viewerMentionTintRow: boolean;
+}) {
+ const needsProfile = needsHyphaProfileForMatrixLabel(syncLabel, fullMxid);
+ const { privyUserId: linkedSub, isLoading: loadingLink } =
+ useUserPrivyIdByMatrixId({
+ matrixUserId: needsProfile ? fullMxid : undefined,
+ });
+ const { person, isLoading: loadingPerson } = usePersonBySub({
+ sub: linkedSub,
+ });
+
+ const displayLabel = useMemo(() => {
+ const fromPerson = person ? formatPersonDisplayName(person) : '';
+ const resolved = fromPerson.trim() || syncLabel.trim();
+ if (!resolved) return fullMxid;
+ return resolved.startsWith('@') ? resolved : `@${resolved}`;
+ }, [person, syncLabel, fullMxid]);
+
+ const loading =
+ needsProfile && (loadingLink || (Boolean(linkedSub) && loadingPerson));
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {displayLabel}
+
+ );
+}
+
+/**
+ * Render plaintext with Matrix MXIDs as pills showing room display names (`resolveMx`).
+ * Non‑MXID `@handles` keep optional Discord-style pills (no colon in capture).
+ */
+export function renderTextWithMentions(
+ text: string,
+ resolveMx: (matrixUserId: string) => string,
+ viewerMentionTintRow = false,
+): React.ReactNode[] {
+ const segments = splitPlainTextMatrixMentions(text);
+ const localHandleRe = /(^|[^\w.+-])@([^\s@]{1,100}?)(?=\s|$|[.,!?;:])/g;
+
+ const mapPlainFragmentWithMentionsOnly = (
+ fragment: string,
+ keyBase: string,
+ ): React.ReactNode[] => {
+ const chunks: React.ReactNode[] = [];
+ let last = 0;
+ let mh: RegExpExecArray | null;
+ const reLocal = new RegExp(localHandleRe.source, localHandleRe.flags);
+ let keyN = 0;
+ while ((mh = reLocal.exec(fragment)) !== null) {
+ const prefix = mh[1] ?? '';
+ const handle = mh[2]?.trim() ?? '';
+ const mentionStart = mh.index + prefix.length;
+ if (mentionStart > last) {
+ chunks.push(
+
+ {fragment.slice(last, mentionStart)}
+ ,
+ );
+ }
+ chunks.push(
+
+ @{handle}
+ ,
+ );
+ last = mentionStart + handle.length + 1;
+ }
+ if (last < fragment.length) {
+ chunks.push(
+
{fragment.slice(last)} ,
+ );
+ }
+ if (chunks.length === 0 && fragment) {
+ return [
{fragment} ];
+ }
+ return chunks;
+ };
+
+ const mapPlainFragment = (
+ fragment: string,
+ keyBase: string,
+ ): React.ReactNode[] => {
+ const pieces = splitPlainTextUrls(fragment);
+ const out: React.ReactNode[] = [];
+ let pieceIdx = 0;
+ for (const piece of pieces) {
+ if (piece.kind === 'text') {
+ out.push(
+ ...mapPlainFragmentWithMentionsOnly(
+ piece.value,
+ `${keyBase}-tx${pieceIdx++}`,
+ ),
+ );
+ } else {
+ const linkKey = `${keyBase}-url${pieceIdx++}`;
+ const linkEl = renderPlainUrlLink(piece.href, linkKey);
+ if (linkEl) out.push(linkEl);
+ if (piece.trailing) {
+ out.push(
+ ...mapPlainFragmentWithMentionsOnly(
+ piece.trailing,
+ `${keyBase}-trail${pieceIdx++}`,
+ ),
+ );
+ }
+ }
+ }
+ return out;
+ };
+
+ const parts: React.ReactNode[] = [];
+ let segIdx = 0;
+ for (const seg of segments) {
+ if (seg.kind === 'mxid') {
+ const syncLabel = resolveMx(seg.full).trim();
+ parts.push(
+
,
+ );
+ } else if (seg.value) {
+ parts.push(...mapPlainFragment(seg.value, `seg-${segIdx}`));
+ }
+ segIdx += 1;
+ }
+ return parts;
+}
+
+function resolveMatrixPlainAndHtmlFragments(
+ fragment: string,
+ resolveMx: (matrixUserId: string) => string,
+ viewerMentionTintRow = false,
+): React.ReactNode[] {
+ return renderTextWithMentions(fragment, resolveMx, viewerMentionTintRow);
+}
+
function reactionTooltipText(
reaction: Reaction,
resolveLabel: (userId: string) => string,
@@ -1439,7 +1632,7 @@ export function HumanChatPanelMessageBubble({
'border-l-[3px] border-l-accent-9 bg-muted/75 dark:border-l-accent-10 dark:bg-muted/55',
unreadBoundary &&
!highlightMentionForViewer &&
- 'border-l-[3px] border-l-accent-8 bg-accent-1 dark:border-l-accent-9 dark:bg-accent-2/45',
+ 'border-l-[3px] border-l-border bg-muted/75 dark:border-l-border dark:bg-muted/55',
)}
onPointerEnter={onRowPointerEnter}
onPointerLeave={onRowPointerLeave}
diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-tabs.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-tabs.tsx
index 7104908cd4..e01451a6c3 100644
--- a/packages/epics/src/common/human-chat-panel/human-chat-panel-tabs.tsx
+++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-tabs.tsx
@@ -1,7 +1,7 @@
'use client';
import type { ReactNode } from 'react';
-import { useLayoutEffect, useRef } from 'react';
+import { useCallback, useLayoutEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { cn } from '@hypha-platform/ui-utils';
@@ -32,6 +32,26 @@ export function HumanChatPanelTabs({
const t = useTranslations('HumanChatPanel');
const tabRailScrollRef = useRef
(null);
+ /** Keep the active tab fully visible inside the horizontally scrollable rail (narrow panels / many tabs). */
+ const scrollTabIntoRailIfClipped = useCallback((tabKey: ChatPanelTab) => {
+ const el = document.getElementById(`chat-tab-${tabKey}`);
+ const rail = tabRailScrollRef.current;
+ if (!el || !rail) return;
+ const elRect = el.getBoundingClientRect();
+ const railRect = rail.getBoundingClientRect();
+ const pad = 2;
+ if (
+ elRect.left < railRect.left + pad ||
+ elRect.right > railRect.right - pad
+ ) {
+ el.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ inline: 'nearest',
+ });
+ }
+ }, []);
+
const chatBadgeLabel =
chatMentionCount > 0
? chatMentionCountCapped || chatMentionCount >= 100
@@ -75,20 +95,8 @@ export function HumanChatPanelTabs({
const hasEndCluster = Boolean(tabRowEnd);
useLayoutEffect(() => {
- if (activeTab !== 'mentions') return;
- const el = document.getElementById('chat-tab-mentions');
- const rail = tabRailScrollRef.current;
- if (!el || !rail) return;
- const elRect = el.getBoundingClientRect();
- const railRect = rail.getBoundingClientRect();
- if (elRect.left < railRect.left || elRect.right > railRect.right) {
- el.scrollIntoView({
- behavior: 'smooth',
- block: 'nearest',
- inline: 'nearest',
- });
- }
- }, [activeTab]);
+ scrollTabIntoRailIfClipped(activeTab);
+ }, [activeTab, scrollTabIntoRailIfClipped]);
return (
onTabChange(tab.key)}
+ onClick={() => {
+ onTabChange(tab.key);
+ requestAnimationFrame(() =>
+ scrollTabIntoRailIfClipped(tab.key),
+ );
+ }}
onKeyDown={(e) => handleKeyDown(e, index)}
className={cn(
'shrink-0 select-none',
diff --git a/packages/epics/src/common/human-chat-panel/matrix-chat-unread.ts b/packages/epics/src/common/human-chat-panel/matrix-chat-unread.ts
index b467f3fa94..2743875b2f 100644
--- a/packages/epics/src/common/human-chat-panel/matrix-chat-unread.ts
+++ b/packages/epics/src/common/human-chat-panel/matrix-chat-unread.ts
@@ -1,6 +1,7 @@
import {
EventType,
NotificationCountType,
+ type MatrixClient,
type MatrixEvent,
type Room,
} from 'matrix-js-sdk';
@@ -9,6 +10,7 @@ import {
contentMentionsMatrixUser,
getMessageReplaceTargetEventId,
isRedactedRoomMessageEvent,
+ stripMatrixReplyFallback,
} from '@hypha-platform/core/client';
export type HumanChatUnreadState = {
@@ -212,3 +214,105 @@ export function computeHumanChatUnreadState(
mentionCountIsCapped,
};
}
+
+/** Sum unread @-mention notifications across every room the user has joined (space chats + DM). */
+export function computeAggregateUnreadMentionCount(
+ rooms: Room[],
+ userId: string | null,
+): { count: number; capped: boolean } {
+ if (!userId || rooms.length === 0) {
+ return { count: 0, capped: false };
+ }
+ let total = 0;
+ for (const room of rooms) {
+ if (room.getMyMembership() !== 'join') continue;
+ const readUpToId = effectiveReadCursorEventId(room, userId);
+ total += countUnreadMentionMessagesForUser(room, userId, readUpToId);
+ if (total >= 100) {
+ return { count: total, capped: true };
+ }
+ }
+ return { count: total, capped: total >= 100 };
+}
+
+export type AggregatedMentionPreview = {
+ roomId: string;
+ roomDisplayName: string;
+ eventId: string;
+ senderId: string;
+ excerpt: string;
+ timestamp: number;
+};
+
+/** Room title for aggregated inbox rows (canonical alias, name, or shortened id). */
+export function matrixRoomShortLabel(room: Room): string {
+ const canonical = room.getCanonicalAlias()?.trim();
+ if (canonical) return canonical;
+ const name = room.name?.trim();
+ if (name) return name;
+ return shortenRoomIdForDisplay(room.roomId);
+}
+
+function shortenRoomIdForDisplay(roomId: string): string {
+ if (!roomId.startsWith('!')) return roomId;
+ const rest = roomId.slice(1);
+ const colonIdx = rest.indexOf(':');
+ if (colonIdx <= 0) return roomId;
+ const sigil = rest.slice(0, colonIdx);
+ const domain = rest.slice(colonIdx + 1);
+ const short =
+ sigil.length <= 14 ? sigil : `${sigil.slice(0, 8)}…${sigil.slice(-4)}`;
+ return `!${short}:${domain}`;
+}
+
+/**
+ * Latest @-mention rows across joined rooms (newest first). Reuses mention detection
+ * from {@link gatherMentionEvents} / timeline rules.
+ */
+export function gatherAggregatedMentionPreviews(
+ client: MatrixClient,
+ userId: string,
+ limit: number,
+): AggregatedMentionPreview[] {
+ const rows: AggregatedMentionPreview[] = [];
+ const rooms = client.getRooms().filter((r) => r.getMyMembership() === 'join');
+
+ for (const room of rooms) {
+ const timeline = room.getLiveTimeline().getEvents();
+ const replacementsByRootId = replacementEventsByRootId(timeline);
+ for (let i = timeline.length - 1; i >= 0; i--) {
+ const ev = timeline[i];
+ if (!ev) continue;
+ if (ev.getType() !== EventType.RoomMessage) continue;
+ if (!ev.getId()) continue;
+ const sender = ev.getSender();
+ if (!sender || sender === userId) continue;
+ if (isRedactedRoomMessageEvent(ev)) continue;
+ if (getMessageReplaceTargetEventId(ev) != null) continue;
+
+ const wire = wireContentForMentionParse(ev, replacementsByRootId);
+ if (!contentMentionsMatrixUser(wire, userId)) continue;
+
+ const raw =
+ typeof wire?.body === 'string'
+ ? wire.body
+ : typeof ev.getContent() === 'object' &&
+ ev.getContent() &&
+ typeof (ev.getContent() as { body?: string }).body === 'string'
+ ? (ev.getContent() as { body: string }).body
+ : '';
+ const excerpt = stripMatrixReplyFallback(raw).trim().slice(0, 280);
+ rows.push({
+ roomId: room.roomId,
+ roomDisplayName: matrixRoomShortLabel(room),
+ eventId: ev.getId()!,
+ senderId: sender,
+ excerpt,
+ timestamp: ev.getTs(),
+ });
+ }
+ }
+
+ rows.sort((a, b) => b.timestamp - a.timestamp);
+ return rows.slice(0, limit);
+}
diff --git a/packages/epics/src/common/human-chat-panel/use-resolved-mention-candidate-label.ts b/packages/epics/src/common/human-chat-panel/use-resolved-mention-candidate-label.ts
new file mode 100644
index 0000000000..9384626ff3
--- /dev/null
+++ b/packages/epics/src/common/human-chat-panel/use-resolved-mention-candidate-label.ts
@@ -0,0 +1,84 @@
+'use client';
+
+import { useAuthentication } from '@hypha-platform/authentication';
+import { useMemo } from 'react';
+import {
+ useJwt,
+ useUserPrivyIdByMatrixId,
+ usePersonBySub,
+} from '@hypha-platform/core/client';
+
+/** Minimal fields for Matrix→Person resolution (matches mention picker rows). */
+export type MentionPickCandidate = {
+ userId: string;
+ displayLabel: string;
+ privySub?: string;
+};
+
+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() ?? '';
+}
+
+/**
+ * Same resolution as {@link HumanChatMentionCandidateRow}: Matrix → Privy link → Person profile,
+ * else Matrix fallback label (may be shortened MXID for bridged users).
+ */
+export function useResolvedMentionCandidateLabel(
+ candidate: MentionPickCandidate | null,
+): {
+ resolvedLabel: string;
+ busy: boolean;
+ avatarUrl?: string;
+ /** Same as original row: block pick until Matrix→Privy link resolves when no roster sub. */
+ pickDisabled: boolean;
+} {
+ const matrixUserId = candidate?.userId ?? '';
+ const matrixFallbackLabel = candidate?.displayLabel ?? '';
+ const privySub = candidate?.privySub;
+
+ const { privyUserId: linkedSub, isLoading: loadingLink } =
+ useUserPrivyIdByMatrixId({
+ matrixUserId: privySub || !matrixUserId ? undefined : matrixUserId,
+ });
+ const resolvedSub = privySub ?? linkedSub;
+ const { user } = useAuthentication();
+ const { jwt, isLoadingJwt } = useJwt();
+ const { person, isLoading: loadingPerson } = usePersonBySub({
+ sub: resolvedSub,
+ });
+
+ const resolvedLabel = useMemo(() => {
+ if (!candidate) return '';
+ const fromPerson = person ? formatHyphaPersonName(person) : '';
+ return fromPerson || matrixFallbackLabel;
+ }, [candidate, person, matrixFallbackLabel]);
+
+ /**
+ * Person fetch needs JWT; `usePersonBySub` is idle until then. Block pick while auth is
+ * still resolving JWT (but not forever when logged out — no user to wait for).
+ */
+ const jwtBlockingForPerson =
+ Boolean(resolvedSub) &&
+ ((user && isLoadingJwt && !jwt) || (!user && isLoadingJwt));
+ /** True while Matrix→Privy link, JWT bootstrap, or Person profile is still loading. */
+ const busy =
+ Boolean(candidate) &&
+ ((!privySub && loadingLink) ||
+ (Boolean(resolvedSub) && (loadingPerson || jwtBlockingForPerson)));
+
+ const avatarUrl = person?.avatarUrl?.trim() || undefined;
+
+ return {
+ resolvedLabel,
+ busy,
+ avatarUrl,
+ /** Block pick until Hypha resolution settles so we never insert shortened MXID by mistake. */
+ pickDisabled: busy,
+ };
+}
diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx
index e46ed0811a..1e92ce48e2 100644
--- a/packages/epics/src/common/human-right-panel.tsx
+++ b/packages/epics/src/common/human-right-panel.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Minimize2 } from 'lucide-react';
import {
+ ClientEvent,
RoomStateEvent,
type MatrixClient,
type MatrixEvent,
@@ -84,13 +85,21 @@ import {
} from './human-chat-panel';
import type { ChatPanelTab } from './human-chat-panel';
import { useHumanChatPanel } from './human-chat-panel-context';
-import { computeHumanChatUnreadState } from './human-chat-panel/matrix-chat-unread';
+import {
+ computeAggregateUnreadMentionCount,
+ computeHumanChatUnreadState,
+} from './human-chat-panel/matrix-chat-unread';
import {
matrixMemberDisplayLabel,
shortenMatrixIdForDisplay,
} from './human-chat-panel/matrix-room-member-display';
import { getActiveTabFromPath } from './get-active-tab-from-path';
+import { getDhoSpaceSlugFromPathname } from './get-dho-space-slug-from-pathname';
import { useCallJoinChime } from './human-chat-panel/use-call-join-chime';
+import {
+ sanitizeMentionDisplayLabel,
+ wireComposerPlainForMatrixSend,
+} from './human-chat-panel/human-chat-display-mention';
function personRosterLabel(p: Person, unknownLabel: string): string {
const full = [p.name, p.surname].filter(Boolean).join(' ').trim();
@@ -107,6 +116,39 @@ function disposeDraftAttachmentUrls(drafts: ChatDraftAttachment[]) {
}
}
+/** Sanitized labels shared by multiple members need a disambiguated composer token + map key. */
+function computeDuplicateSanitizedDisplayKeys(
+ mentionLabelByUserId: ReadonlyMap,
+): Set {
+ const counts = new Map();
+ for (const label of mentionLabelByUserId.values()) {
+ const k = sanitizeMentionDisplayLabel(label);
+ if (!k) continue;
+ counts.set(k, (counts.get(k) ?? 0) + 1);
+ }
+ const dup = new Set();
+ for (const [k, n] of counts) {
+ if (n > 1) dup.add(k);
+ }
+ return dup;
+}
+
+function disambiguatedMentionTokenKey(
+ userId: string,
+ displayLabel: string,
+ duplicateKeys: ReadonlySet,
+): string {
+ let key = sanitizeMentionDisplayLabel(displayLabel);
+ if (!key) return '';
+ if (!duplicateKeys.has(key)) return key;
+ const stem = shortenMatrixIdForDisplay(userId).replace(/^@/, '').trim();
+ const short =
+ stem.length <= 26 ? stem : `${stem.slice(0, 12)}…${stem.slice(-8)}`;
+ key = sanitizeMentionDisplayLabel(`${displayLabel} (${short})`);
+ if (!key) return sanitizeMentionDisplayLabel(userId);
+ return key;
+}
+
type UIMessage = {
id: string;
role: 'user' | 'member';
@@ -166,6 +208,39 @@ type EditDraft = {
const ROOM_STORAGE_KEY = 'hypha-chat-room-';
+const SESSION_ROOM_TO_SPACE_PREFIX = 'hypha-room-to-space-';
+
+/** Reverse map for navigating from Matrix room id → DHO space slug (localStorage). */
+function readRoomIdToSpaceSlugFromStorage(): Map {
+ const m = new Map();
+ if (typeof window === 'undefined') return m;
+ try {
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const key = window.localStorage.key(i);
+ if (!key?.startsWith(ROOM_STORAGE_KEY)) continue;
+ const slug = key.slice(ROOM_STORAGE_KEY.length);
+ if (!slug) continue;
+ const rid = window.localStorage.getItem(key)?.trim();
+ if (rid) m.set(rid, slug);
+ }
+ } catch {
+ // ignore
+ }
+ return m;
+}
+
+function rememberRoomToSpaceSlugSession(roomId: string, slug: string): void {
+ if (typeof window === 'undefined') return;
+ try {
+ window.sessionStorage.setItem(
+ `${SESSION_ROOM_TO_SPACE_PREFIX}${roomId}`,
+ slug,
+ );
+ } catch {
+ // ignore
+ }
+}
+
/**
* Get a persisted room ID for a space slug from localStorage.
*/
@@ -487,6 +562,11 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
currentUserAvatarUrlRef.current = currentUserAvatarUrl;
const [input, setInput] = useState('');
+ /** Hypha-resolved names from the mention picker (may differ from Matrix fallback displayLabel). */
+ const [mentionDisplayOverride, setMentionDisplayOverride] = useState<
+ Record
+ >({});
+
const [draftAttachments, setDraftAttachments] = useState<
ChatDraftAttachment[]
>([]);
@@ -499,6 +579,11 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
const [replyDraft, setReplyDraft] = useState(null);
const [editDraft, setEditDraft] = useState(null);
const [roomId, setRoomId] = useState(null);
+
+ useEffect(() => {
+ setMentionDisplayOverride({});
+ }, [roomId]);
+
const [isJoining, setIsJoining] = useState(false);
const [error, setError] = useState(null);
const [reactionError, setReactionError] = useState(null);
@@ -537,6 +622,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
}>(null);
const joinedRef = useRef(null);
const [unreadBump, setUnreadBump] = useState(0);
+ const [aggregateMentionBump, setAggregateMentionBump] = useState(0);
const lastAutoMarkReadAtRef = useRef(0);
const currentUserId = client?.getUserId?.() ?? null;
@@ -573,6 +659,8 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
tabBackgroundWhileInCall: spaceCallTabBackground,
retryFromError: retrySpaceCall,
dismissCallError: dismissSpaceCallError,
+ remoteMediaStall: spaceCallRemoteMediaStall,
+ dismissRemoteMediaStallBanner: dismissSpaceCallRemoteMediaStall,
} = useSpaceGroupCall(mode === 'space' ? roomId : null);
const callUiEnabled = useMemo(
@@ -596,6 +684,13 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
const spaceCallToolbarJoinHint = callUiEnabled && spaceCallShowJoinStrip;
+ /** Distinct Matrix users in the room call besides the current user (not device count). */
+ const spaceCallOtherMemberCount = useMemo(
+ () =>
+ spaceCallInCallUserIds.filter((id) => id && id !== currentUserId).length,
+ [spaceCallInCallUserIds, currentUserId],
+ );
+
const spaceCallShowJoinChime = useMemo(
() => callUiEnabled && spaceCallShowJoinStrip && !inSpaceCall,
[callUiEnabled, spaceCallShowJoinStrip, inSpaceCall],
@@ -652,6 +747,33 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
setCallLeftMessage(k === 'video' ? t('callLeftVideo') : t('callLeftAudio'));
}, [leaveSpaceCall, spaceCallKind, t]);
+ /** End ghost sessions: when connected and no other participants for 5 minutes, leave locally. */
+ useEffect(() => {
+ if (!callUiEnabled || spaceCallState !== 'connected') return;
+ if (spaceCallOthersInRoom > 0 || spaceCallRoomGroupDeviceCount === 0)
+ return;
+
+ const SOLO_IDLE_LEAVE_MS = 5 * 60 * 1000;
+ const id = window.setTimeout(() => {
+ void leaveSpaceCall();
+ setCallLeftMessage(
+ spaceCallKind === 'video'
+ ? t('callLeftVideoIdle')
+ : t('callLeftAudioIdle'),
+ );
+ }, SOLO_IDLE_LEAVE_MS);
+
+ return () => window.clearTimeout(id);
+ }, [
+ callUiEnabled,
+ spaceCallState,
+ spaceCallOthersInRoom,
+ spaceCallRoomGroupDeviceCount,
+ leaveSpaceCall,
+ spaceCallKind,
+ t,
+ ]);
+
const handleCallToggleMic = useCallback(() => {
void setSpaceCallMicMuted(!spaceCallMicMuted);
}, [setSpaceCallMicMuted, spaceCallMicMuted]);
@@ -817,21 +939,73 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
mentionMembershipEpoch,
]);
+ const mergeMentionDisplayLabel = useCallback(
+ (userId: string, displayLabel: string) => {
+ const trimmedLabel = displayLabel.trim();
+ if (!trimmedLabel) return;
+ setMentionDisplayOverride((prev) => {
+ if (prev[userId] === trimmedLabel) return prev;
+ return { ...prev, [userId]: trimmedLabel };
+ });
+ },
+ [],
+ );
+
const mentionLabelByUserId = useMemo(
() =>
new Map(
- mentionCandidates.map((candidate) => [
- candidate.userId,
- candidate.displayLabel,
- ]),
+ mentionCandidates.map((candidate) => {
+ const o = mentionDisplayOverride[candidate.userId];
+ return [
+ candidate.userId,
+ o?.trim() ? o : candidate.displayLabel,
+ ] as const;
+ }),
),
- [mentionCandidates],
+ [mentionCandidates, mentionDisplayOverride],
+ );
+
+ const duplicateSanitizedDisplayKeys = useMemo(
+ () => computeDuplicateSanitizedDisplayKeys(mentionLabelByUserId),
+ [mentionLabelByUserId],
+ );
+
+ /** Sanitized display label → MXID for converting composer `@Name` tokens before Matrix send. */
+ const mentionSanitizedLabelToUserId = useMemo(() => {
+ const m = new Map();
+ for (const [userId, label] of mentionLabelByUserId) {
+ const key = disambiguatedMentionTokenKey(
+ userId,
+ label,
+ duplicateSanitizedDisplayKeys,
+ );
+ if (!key) continue;
+ m.set(key, userId);
+ }
+ return m;
+ }, [mentionLabelByUserId, duplicateSanitizedDisplayKeys]);
+
+ const getMentionComposerLabel = useCallback(
+ (member: ChatMentionCandidate, resolvedComposerLabel?: string) => {
+ const label = resolvedComposerLabel?.trim() || member.displayLabel;
+ return (
+ disambiguatedMentionTokenKey(
+ member.userId,
+ label,
+ duplicateSanitizedDisplayKeys,
+ ) || label
+ );
+ },
+ [duplicateSanitizedDisplayKeys],
);
const resolveMentionMemberLabel = useCallback(
- (userId: string) =>
- mentionLabelByUserId.get(userId) ?? resolveMemberLabel(userId),
- [mentionLabelByUserId, resolveMemberLabel],
+ (userId: string | undefined) => {
+ const id = userId?.trim();
+ if (!id) return t('unknownMember');
+ return mentionLabelByUserId.get(id) ?? resolveMemberLabel(id);
+ },
+ [mentionLabelByUserId, resolveMemberLabel, t],
);
/** Same roster merge as pills — timeline sender/reply headers use this first. */
@@ -874,10 +1048,34 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
if (!roomId || !client) return;
setMessages((prev) =>
prev.map((m) => {
- const newSenderName =
- m.role === 'member' && m.senderMatrixId
- ? resolveMemberLabelRef.current(m.senderMatrixId)
- : m.senderName;
+ const sid = m.senderMatrixId?.trim();
+ const isSelfRow = Boolean(
+ currentUserIdRef.current && sid && sid === currentUserIdRef.current,
+ );
+
+ /**
+ * On cold load, `client.getUserId()` can be null when we first map
+ * Matrix events to UI — own messages are misclassified as `member` and
+ * get a technical Matrix/Privy display label. Re-sync when Matrix id arrives.
+ */
+ let nextRole = m.role;
+ let newSenderName = m.senderName;
+ let nextMemberAvatar = m.avatarUrl;
+ if (isSelfRow) {
+ nextRole = 'user';
+ newSenderName = undefined;
+ nextMemberAvatar = currentUserAvatarUrlRef.current ?? m.avatarUrl;
+ } else if (m.role === 'member' && sid) {
+ newSenderName = resolveMemberLabelRef.current(sid);
+ nextMemberAvatar =
+ matrixMemberAvatarSquare(
+ matrixClientRef.current,
+ roomIdRef.current,
+ sid,
+ 96,
+ ) ?? m.avatarUrl;
+ }
+
const newAuthorLabel =
m.replyTo?.sourceUserId != null
? resolveMemberLabelRef.current(m.replyTo.sourceUserId)
@@ -902,17 +1100,8 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
}
: m.replyTo;
- const nextMemberAvatar =
- m.role === 'member' && m.senderMatrixId
- ? matrixMemberAvatarSquare(
- matrixClientRef.current,
- roomIdRef.current,
- m.senderMatrixId,
- 96,
- ) ?? m.avatarUrl
- : m.avatarUrl;
-
if (
+ nextRole === m.role &&
newSenderName === m.senderName &&
nextReply?.authorLabel === m.replyTo?.authorLabel &&
nextReply?.authorAvatarUrl === m.replyTo?.authorAvatarUrl &&
@@ -923,13 +1112,22 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
return {
...m,
+ role: nextRole,
senderName: newSenderName,
avatarUrl: nextMemberAvatar,
replyTo: nextReply,
};
}),
);
- }, [roomId, client, currentUserId, me?.name, me?.surname, t]);
+ }, [
+ roomId,
+ client,
+ currentUserId,
+ currentUserAvatarUrl,
+ me?.name,
+ me?.surname,
+ t,
+ ]);
// Backfill avatar on self-authored messages after useMe() resolves
useEffect(() => {
@@ -1378,10 +1576,52 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
return `/${lang}/notification-centre`;
}, [pathname, params?.id, spaceSlug]);
- const handleSelectMentionFromInbox = useCallback((eventId: string) => {
- setActiveTab('chat');
- setScrollToEventId(eventId);
- }, []);
+ const handleSelectMentionFromInbox = useCallback(
+ (eventId: string, fromRoomId?: string) => {
+ const targetRoom = fromRoomId?.trim();
+ const current = roomId?.trim();
+ const langMatch = pathname.match(/^\/([^/]+)\//);
+ const lang = langMatch?.[1] ?? 'en';
+
+ if (
+ targetRoom &&
+ current &&
+ targetRoom !== current &&
+ typeof window !== 'undefined'
+ ) {
+ let slug =
+ window.sessionStorage
+ .getItem(`${SESSION_ROOM_TO_SPACE_PREFIX}${targetRoom}`)
+ ?.trim() ?? null;
+ if (!slug) {
+ const fromLs = readRoomIdToSpaceSlugFromStorage().get(targetRoom);
+ slug = fromLs ?? null;
+ }
+ if (!slug && space?.chatRoomId?.trim() === targetRoom && spaceSlug) {
+ slug = spaceSlug;
+ }
+ if (slug) {
+ router.push(
+ `/${lang}/dho/${slug}?msg=${encodeURIComponent(eventId)}`,
+ );
+ openHumanChatPanel();
+ setActiveTab('chat');
+ return;
+ }
+ }
+
+ setActiveTab('chat');
+ setScrollToEventId(eventId);
+ },
+ [
+ roomId,
+ pathname,
+ router,
+ openHumanChatPanel,
+ space?.chatRoomId,
+ spaceSlug,
+ ],
+ );
const handleConsumedScrollTarget = useCallback(() => {
setScrollToEventId(null);
@@ -1423,6 +1663,36 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
};
}, [client, roomId]);
+ useEffect(() => {
+ if (
+ typeof window === 'undefined' ||
+ mode !== 'space' ||
+ !spaceSlug?.trim() ||
+ !roomId?.trim()
+ )
+ return;
+ rememberRoomToSpaceSlugSession(roomId, spaceSlug.trim());
+ }, [mode, spaceSlug, roomId]);
+
+ useEffect(() => {
+ const pathSlug = getDhoSpaceSlugFromPathname(pathname)?.trim();
+ const rid = roomId?.trim() ?? null;
+ if (pathSlug && rid && mode === 'space' && typeof window !== 'undefined') {
+ rememberRoomToSpaceSlugSession(rid, pathSlug);
+ }
+ }, [pathname, roomId, mode]);
+
+ useEffect(() => {
+ if (!client || !currentUserId) return;
+ const bump = () => setAggregateMentionBump((n) => n + 1);
+ client.on(ClientEvent.Sync, bump);
+ client.on(ClientEvent.Room, bump);
+ return () => {
+ client.removeListener(ClientEvent.Sync, bump);
+ client.removeListener(ClientEvent.Room, bump);
+ };
+ }, [client, currentUserId]);
+
const unreadChatState = useMemo(() => {
if (!client || !roomId || !currentUserId) {
return {
@@ -1437,6 +1707,16 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
return computeHumanChatUnreadState(room ?? undefined, currentUserId);
}, [client, roomId, currentUserId, unreadBump, mergedMessages.length]);
+ const aggregateMentionBadge = useMemo(() => {
+ if (!client || !currentUserId) {
+ return { count: 0, capped: false };
+ }
+ return computeAggregateUnreadMentionCount(client.getRooms(), currentUserId);
+ }, [client, currentUserId, aggregateMentionBump, unreadBump]);
+
+ const bellMentionCount = aggregateMentionBadge.count;
+ const bellMentionCapped = aggregateMentionBadge.capped;
+
const markChatTimelineRead = useCallback(async () => {
if (!client || !roomId || !currentUserId) return;
const room = client.getRoom(roomId);
@@ -1476,7 +1756,10 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
if (mode !== 'space') return;
const qpChat = searchParams?.get('chat')?.trim();
const qpMsg = searchParams?.get('msg')?.trim();
- if (!qpChat || !qpMsg || !roomId || qpChat !== roomId) return;
+ if (!qpMsg || !roomId) return;
+ /** Short link: `?msg=` only (same space room). Legacy: `?chat=` + `msg`. */
+ const sameRoom = (!qpChat && roomId) || (qpChat && qpChat === roomId);
+ if (!sameRoom) return;
openHumanChatPanel();
setActiveTab('chat');
@@ -1673,6 +1956,10 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
setDraftAttachments([]);
const pendingId =
savedAttachments.length > 0 ? `hypha-send-pending-${Date.now()}` : null;
+ const { wirePlain, mentionUserIds } = wireComposerPlainForMatrixSend(
+ text,
+ mentionSanitizedLabelToUserId,
+ );
if (pendingId) {
setSendingPending({
id: pendingId,
@@ -1706,7 +1993,8 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
await matrixRef.current.editRoomMessage({
roomId,
targetEventId: editTargetEventId,
- message: text,
+ message: wirePlain,
+ mentionUserIds,
existingMediaSlots: slots,
...(newFiles.length > 0 ? { newAttachments: newFiles } : {}),
...(newFiles.length > 0 ? { signal } : {}),
@@ -1718,13 +2006,15 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
await matrixRef.current.editRoomMessage({
roomId,
targetEventId: editTargetEventId,
- message: text,
+ message: wirePlain,
+ mentionUserIds,
});
}
} else {
await matrixRef.current.sendMessage({
roomId,
- message: text,
+ message: wirePlain,
+ mentionUserIds,
signal,
...(replyToEventId ? { replyToEventId } : {}),
...(savedAttachments.length > 0
@@ -1814,7 +2104,15 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
setEditDraft(savedEditDraft);
setDraftAttachments(savedAttachments);
}
- }, [input, roomId, replyDraft, editDraft, draftAttachments, t]);
+ }, [
+ input,
+ roomId,
+ replyDraft,
+ editDraft,
+ draftAttachments,
+ mentionSanitizedLabelToUserId,
+ t,
+ ]);
useEffect(() => {
return () => {
@@ -1832,8 +2130,8 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
trailingStart={
roomId ? (
setActiveTab('mentions')}
callJoinRingControlsActive={
@@ -1848,10 +2146,10 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
)}
@@ -2061,6 +2363,8 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
onSend={handleSend}
mentionCandidates={mentionCandidates}
mentionPickerEnabled={mentionPickerEnabled}
+ getMentionComposerLabel={getMentionComposerLabel}
+ onMergeMentionDisplayLabel={mergeMentionDisplayLabel}
draftAttachments={draftAttachments}
onDraftAttachmentsChange={setDraftAttachments}
replyPreview={
@@ -2150,8 +2454,9 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
activeSpeakerKey={spaceCallActiveSpeakerKey}
currentUserId={currentUserId}
inCallUserIds={spaceCallInCallUserIds}
+ remoteMediaStall={spaceCallRemoteMediaStall}
currentUserProfileAvatarUrl={currentUserAvatarUrl}
- resolveMemberLabel={resolveMemberLabel}
+ resolveMemberLabel={resolveMentionMemberLabel}
layout="fullView"
fullViewOpen
fullViewLayoutMode={callFullViewLayoutMode}
diff --git a/packages/epics/src/common/panel-main-column-scroll-bridge.tsx b/packages/epics/src/common/panel-main-column-scroll-bridge.tsx
index 43aa0b8223..4aa8cb0160 100644
--- a/packages/epics/src/common/panel-main-column-scroll-bridge.tsx
+++ b/packages/epics/src/common/panel-main-column-scroll-bridge.tsx
@@ -89,10 +89,12 @@ export function PanelDualSidebarScrollBridge({
(MenuTop, plugin, main, Footer) as a fragment — fragments flatten, so without this
wrapper they become **separate flex items** next to the right Sidebar: header | content
| footer | panel in one horizontal row.
+ `overflow-x-hidden`: Human/AI panels are `position:fixed`; clip horizontal pan so the
+ scrollport cannot reveal a dead gap beside the fixed rails.
*/}
{children}
diff --git a/packages/i18n/src/messages/de.json b/packages/i18n/src/messages/de.json
index 1e5fbc9e8b..04bbb0fca4 100644
--- a/packages/i18n/src/messages/de.json
+++ b/packages/i18n/src/messages/de.json
@@ -1923,6 +1923,8 @@
"callSearchComingSoon": "Im Chat suchen — demnächst",
"callActiveInSpace": "Space-Anruf (alle in diesem Space können beitreten)",
"callConnecting": "Verbinden…",
+ "callRemoteParticipantMediaStalled": "Warte auf Audio/Video — Netzwerk prüfen oder Anruf verlassen und erneut beitreten.",
+ "callRemoteMediaStallHint": "Andere sind im Anruf gelistet, aber Audio/Video kommt nicht an — oft Netzwerk oder Browser. Verlassen und erneut beitreten oder das Netzwerk wechseln.",
"callDisconnecting": "Anruf wird beendet…",
"callLeave": "Verlassen",
"callMute": "Mikrofon stummschalten",
@@ -1969,6 +1971,8 @@
"callParticipantMicOff": "Mikrofon aus",
"callLeftAudio": "Du hast den Sprachanruf verlassen.",
"callLeftVideo": "Du hast den Videoanruf verlassen.",
+ "callLeftAudioIdle": "Du hast den Sprachanruf verlassen — nach einer Weile ist niemand beigetreten.",
+ "callLeftVideoIdle": "Du hast den Videoanruf verlassen — nach einer Weile ist niemand beigetreten.",
"callLeftBannerDismiss": "Schließen",
"callPaneResizeSharePeople": "Bildschirmfreigabe und Personen skalieren",
"callPaneResizeShareStrip": "Bildschirmfreigabe und Filmstreifen skalieren",
diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json
index 27e1200b75..6c4d2fe29b 100644
--- a/packages/i18n/src/messages/en.json
+++ b/packages/i18n/src/messages/en.json
@@ -1954,6 +1954,8 @@
"callSearchComingSoon": "Search in chat — coming soon",
"callActiveInSpace": "Space call (everyone in this space can join)",
"callConnecting": "Connecting…",
+ "callRemoteParticipantMediaStalled": "Waiting for audio/video — check network or try leaving and rejoining.",
+ "callRemoteMediaStallHint": "Others are listed in this call but their audio/video is not arriving — often a network or browser issue. Try leaving and rejoining, or switch networks.",
"callDisconnecting": "Leaving call…",
"callLeave": "Leave",
"callMute": "Mute microphone",
@@ -2000,6 +2002,8 @@
"callParticipantMicOff": "Microphone off",
"callLeftAudio": "You left the voice call.",
"callLeftVideo": "You left the video call.",
+ "callLeftAudioIdle": "You left the voice call — no one else joined after a while.",
+ "callLeftVideoIdle": "You left the video call — no one else joined after a while.",
"callLeftBannerDismiss": "Dismiss",
"callPaneResizeSharePeople": "Resize screen share and people",
"callPaneResizeShareStrip": "Resize screen share and filmstrip",
diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json
index b6a41474ab..669117ad48 100644
--- a/packages/i18n/src/messages/es.json
+++ b/packages/i18n/src/messages/es.json
@@ -1922,6 +1922,8 @@
"callSearchComingSoon": "Buscar en el chat — próximamente",
"callActiveInSpace": "Llamada del espacio (todos en este espacio pueden unirse)",
"callConnecting": "Conectando…",
+ "callRemoteParticipantMediaStalled": "Esperando audio/vídeo — comprueba la red o sal y vuelve a unirte.",
+ "callRemoteMediaStallHint": "Hay otras personas en la llamada pero no llega su audio/vídeo; suele ser red o navegador. Sal y vuelve a unirte o cambia de red.",
"callDisconnecting": "Saliendo de la llamada…",
"callLeave": "Salir",
"callMute": "Silenciar micrófono",
@@ -1968,6 +1970,8 @@
"callParticipantMicOff": "Micrófono desactivado",
"callLeftAudio": "Has salido de la llamada de voz.",
"callLeftVideo": "Has salido de la videollamada.",
+ "callLeftAudioIdle": "Has salido de la llamada de voz — nadie más se unió después de un tiempo.",
+ "callLeftVideoIdle": "Has salido de la videollamada — nadie más se unió después de un tiempo.",
"callLeftBannerDismiss": "Cerrar",
"callPaneResizeSharePeople": "Redimensionar pantalla y personas",
"callPaneResizeShareStrip": "Redimensionar pantalla y tira de vídeo",
diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json
index aa39d9c952..9b37343fd5 100644
--- a/packages/i18n/src/messages/fr.json
+++ b/packages/i18n/src/messages/fr.json
@@ -1922,6 +1922,8 @@
"callSearchComingSoon": "Rechercher dans le chat — bientôt",
"callActiveInSpace": "Appel d’espace (tout le monde dans cet espace peut rejoindre)",
"callConnecting": "Connexion…",
+ "callRemoteParticipantMediaStalled": "En attente de l’audio/vidéo — vérifiez le réseau ou quittez puis rejoignez.",
+ "callRemoteMediaStallHint": "D’autres sont listés dans l’appel mais leur audio/vidéo n’arrive pas — souvent réseau ou navigateur. Quittez puis rejoignez, ou changez de réseau.",
"callDisconnecting": "Sortie de l’appel…",
"callLeave": "Quitter",
"callMute": "Couper le micro",
@@ -1968,6 +1970,8 @@
"callParticipantMicOff": "Micro coupé",
"callLeftAudio": "Vous avez quitté l'appel vocal.",
"callLeftVideo": "Vous avez quitté l'appel vidéo.",
+ "callLeftAudioIdle": "Vous avez quitté l'appel vocal — personne d'autre ne s'est joint après un moment.",
+ "callLeftVideoIdle": "Vous avez quitté l'appel vidéo — personne d'autre ne s'est joint après un moment.",
"callLeftBannerDismiss": "Fermer",
"callPaneResizeSharePeople": "Redimensionner partage d’écran et personnes",
"callPaneResizeShareStrip": "Redimensionner partage d’écran et film",
diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json
index d4fe86921c..ae792256e0 100644
--- a/packages/i18n/src/messages/pt.json
+++ b/packages/i18n/src/messages/pt.json
@@ -1922,6 +1922,8 @@
"callSearchComingSoon": "Pesquisar no chat — em breve",
"callActiveInSpace": "Chamada do espaço (todos neste espaço podem entrar)",
"callConnecting": "A conectar…",
+ "callRemoteParticipantMediaStalled": "À espera de áudio/vídeo — verifique a rede ou saia e volte a entrar.",
+ "callRemoteMediaStallHint": "Outras pessoas aparecem na chamada mas o áudio/vídeo não chega — muitas vezes rede ou navegador. Sai e volta a entrar, ou muda de rede.",
"callDisconnecting": "A sair da chamada…",
"callLeave": "Sair",
"callMute": "Silenciar microfone",
@@ -1968,6 +1970,8 @@
"callParticipantMicOff": "Micro desligado",
"callLeftAudio": "Saiu da chamada de voz.",
"callLeftVideo": "Saiu da videchamada.",
+ "callLeftAudioIdle": "Saiu da chamada de voz — ninguém mais entrou após algum tempo.",
+ "callLeftVideoIdle": "Saiu da videchamada — ninguém mais entrou após algum tempo.",
"callLeftBannerDismiss": "Fechar",
"callPaneResizeSharePeople": "Redimensionar partilha e pessoas",
"callPaneResizeShareStrip": "Redimensionar partilha e filme",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d3cff65ddc..ece4a3b56c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,12 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+overrides:
+ '@hono/node-server': '>=1.19.13 <2'
+ '@xmldom/xmldom': '>=0.9.9'
+ dompurify: '>=3.4.0'
+ hono: '>=4.12.12'
+
importers:
.:
@@ -3595,11 +3601,11 @@ packages:
peerDependencies:
react: '>= 16 || ^19.0.0-rc'
- '@hono/node-server@1.19.12':
- resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==}
+ '@hono/node-server@1.19.14':
+ resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
- hono: ^4
+ hono: '>=4.12.12'
'@hookform/resolvers@4.1.3':
resolution: {integrity: sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==}
@@ -9716,10 +9722,9 @@ packages:
resolution: {integrity: sha512-D+OwTEunoQhVHVToD80dPhfz9xgPLqJyEA3F5jCRM14A2u8tBBQVdZekqfqx6ZAfZ+POT4Hb0dn601UKMsvADw==}
engines: {node: '>=16.0.0'}
- '@xmldom/xmldom@0.9.8':
- resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
+ '@xmldom/xmldom@0.9.10':
+ resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
- deprecated: this version has critical issues, please update to the latest version
'@xstate/store@3.4.3':
resolution: {integrity: sha512-zppaz/lPMWWSW5UOxdwv/Ts2xfpoJm0zHGEKbHzE06fdmSvkdfbbuhG/qNtDH7xidMLA0y2UzCblGdeCRSaxaA==}
@@ -11809,8 +11814,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
- dompurify@3.2.5:
- resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==}
+ dompurify@3.4.1:
+ resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==}
domutils@1.7.0:
resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==}
@@ -13470,8 +13475,8 @@ packages:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
engines: {node: '>=0.10.0'}
- hono@4.12.11:
- resolution: {integrity: sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==}
+ hono@4.12.15:
+ resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==}
engines: {node: '>=16.9.0'}
hosted-git-info@2.8.9:
@@ -23455,9 +23460,9 @@ snapshots:
dependencies:
react: 19.1.2
- '@hono/node-server@1.19.12(hono@4.12.11)':
+ '@hono/node-server@1.19.14(hono@4.12.15)':
dependencies:
- hono: 4.12.11
+ hono: 4.12.15
'@hookform/resolvers@4.1.3(react-hook-form@7.55.0(react@19.1.2))':
dependencies:
@@ -24557,7 +24562,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
- '@hono/node-server': 1.19.12(hono@4.12.11)
+ '@hono/node-server': 1.19.14(hono@4.12.15)
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
content-type: 1.0.5
@@ -24567,7 +24572,7 @@ snapshots:
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.2(express@5.2.1)
- hono: 4.12.11
+ hono: 4.12.15
jose: 6.2.2
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -32918,7 +32923,7 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@xmldom/xmldom@0.9.8': {}
+ '@xmldom/xmldom@0.9.10': {}
'@xstate/store@3.4.3(react@19.1.2)':
optionalDependencies:
@@ -35207,7 +35212,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
- dompurify@3.2.5:
+ dompurify@3.4.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -37606,7 +37611,7 @@ snapshots:
dependencies:
parse-passwd: 1.0.0
- hono@4.12.11: {}
+ hono@4.12.15: {}
hosted-git-info@2.8.9: {}
@@ -39832,7 +39837,7 @@ snapshots:
d3-sankey: 0.12.3
dagre-d3-es: 7.0.11
dayjs: 1.11.13
- dompurify: 3.2.5
+ dompurify: 3.4.1
katex: 0.16.22
khroma: 2.1.0
lodash-es: 4.17.21
@@ -43947,7 +43952,7 @@ snapshots:
speech-rule-engine@4.1.2:
dependencies:
- '@xmldom/xmldom': 0.9.8
+ '@xmldom/xmldom': 0.9.10
commander: 13.1.0
wicked-good-xpath: 1.3.0