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 ( ); 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 && ( + + )} +
+ )} {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 ( -
  • - -
  • - ); - })} + {aggregatedMentions + ? aggregatedRows.map((row) => { + const senderSyncLabel = resolveMemberLabel(row.senderId); + return ( +
  • + +
  • + ); + }) + : 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 ( +
  • + +
  • + ); + })}
)}
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