Panel consensus (the branding panel): don't mount full-app iframes on mobile — nested scrolling, keyboard collapse and double chrome break both Element and oCIS under the bottom tab bar. Chat and Drive render a gateway card (same-tab handoff, matrix: deep link, signed-in-as) below 1024px; the iframe stays on desktop. hub-sso.html pins mx_local_settings.theme to custom-OccupyHub Dark once (version-flagged) — a device's earlier picker choice would otherwise beat config.json's default_theme.
179 lines
6.8 KiB
TypeScript
179 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { EmbeddedApp } from "../../../components/embed/embedded-app";
|
|
import { api, errorMessage } from "../../../lib/api-client";
|
|
import { useApiResource } from "../../../lib/use-api";
|
|
import { useIsMobile } from "../../../hooks/use-is-mobile";
|
|
|
|
/**
|
|
* In-app Chat: Element Web, full height inside the Hub shell, signed in as the
|
|
* Hub user without a Matrix password.
|
|
*
|
|
* Handshake (see infra/occupymedia/edge/hub-sso/chat-sso.html):
|
|
* 1. GET /matrix/identity → the MXID this Hub account maps to.
|
|
* 2. The frame opens Element's origin at /hub-sso.html#expect=<mxid>. If
|
|
* Element already holds a live session for that MXID it goes straight to
|
|
* Element — no token is minted, the device (and its E2EE keys) survives.
|
|
* 3. Otherwise the page posts `tmbh:chat-sso:need-token`; we POST
|
|
* /matrix/session and post the single-use m.login.token back, pinned to
|
|
* Element's origin. Element completes `/?loginToken=…` and keeps its own
|
|
* session from then on.
|
|
* Without SSO configured (or the API offline) the frame shows Element's own
|
|
* sign-in screen: sign in once there and it stays signed in.
|
|
*/
|
|
|
|
/** Same host the nav linked to before Chat moved in-app. */
|
|
const FALLBACK_CHAT_URL = "https://chat.occupymedia.cc";
|
|
/** A frame that keeps asking for tokens is failing token login; stop minting. */
|
|
const MAX_MINTS = 3;
|
|
const ELEMENT_ALLOW = "camera; microphone; display-capture; fullscreen; autoplay; clipboard-read; clipboard-write";
|
|
|
|
interface MatrixIdentity {
|
|
configured: boolean;
|
|
homeserverUrl: string | null;
|
|
elementUrl: string | null;
|
|
userId: string | null;
|
|
}
|
|
|
|
interface MatrixSession {
|
|
homeserverUrl: string;
|
|
elementUrl: string;
|
|
userId: string;
|
|
loginToken: string;
|
|
expiresInMs: number;
|
|
}
|
|
|
|
const identityView = (identity: MatrixIdentity): MatrixIdentity | null => identity;
|
|
|
|
function isNeedToken(data: unknown): boolean {
|
|
return typeof data === "object" && data !== null && "type" in data && data.type === "tmbh:chat-sso:need-token";
|
|
}
|
|
|
|
function originOf(url: string): string | null {
|
|
try {
|
|
return new URL(url).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export default function ChatPage(): React.ReactElement {
|
|
const identity = useApiResource<MatrixIdentity, MatrixIdentity | null>("/matrix/identity", identityView, null);
|
|
const isMobile = useIsMobile();
|
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
|
const mints = useRef(0);
|
|
const tokenSent = useRef(false);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [mintError, setMintError] = useState<string | null>(null);
|
|
|
|
const elementOrigin = originOf(identity.data?.elementUrl ?? FALLBACK_CHAT_URL) ?? FALLBACK_CHAT_URL;
|
|
const ssoUser = identity.data?.configured === true ? identity.data.userId : null;
|
|
const src =
|
|
identity.source === "loading"
|
|
? null
|
|
: ssoUser !== null
|
|
? `${elementOrigin}/hub-sso.html#expect=${encodeURIComponent(ssoUser)}`
|
|
: `${elementOrigin}/`;
|
|
|
|
useEffect(() => {
|
|
if (ssoUser === null) return;
|
|
function onMessage(event: MessageEvent<unknown>): void {
|
|
const target = frameRef.current?.contentWindow ?? null;
|
|
if (target === null || event.source !== target || event.origin !== elementOrigin || !isNeedToken(event.data)) return;
|
|
if (mints.current >= MAX_MINTS) {
|
|
setBusy(null);
|
|
setMintError("Chat sign-in keeps failing. Open chat in a new tab, or retry.");
|
|
return;
|
|
}
|
|
mints.current += 1;
|
|
setBusy("Signing in to chat…");
|
|
api
|
|
.post<MatrixSession>("/matrix/session")
|
|
.then((session) => {
|
|
const current = frameRef.current?.contentWindow ?? null;
|
|
if (current === null) return;
|
|
tokenSent.current = true;
|
|
current.postMessage({ type: "tmbh:chat-sso:token", loginToken: session.loginToken }, elementOrigin);
|
|
})
|
|
.catch((err: unknown) => {
|
|
setBusy(null);
|
|
setMintError(errorMessage(err));
|
|
});
|
|
}
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, [ssoUser, elementOrigin]);
|
|
|
|
// The load after a token was handed over is Element itself: sign-in is done.
|
|
const handleFrameLoad = useCallback(() => {
|
|
if (!tokenSent.current) return;
|
|
tokenSent.current = false;
|
|
setBusy(null);
|
|
}, []);
|
|
|
|
const handleRetry = useCallback(() => {
|
|
mints.current = 0;
|
|
tokenSent.current = false;
|
|
setBusy(null);
|
|
setMintError(null);
|
|
if (identity.error !== null) identity.reload();
|
|
}, [identity]);
|
|
|
|
const notice =
|
|
identity.source === "sample"
|
|
? "Hub offline — sign in to chat directly."
|
|
: identity.source === "live" && identity.error === null && ssoUser === null
|
|
? "Automatic sign-in is not set up — sign in once here and chat stays signed in."
|
|
: ssoUser !== null
|
|
? ssoUser
|
|
: undefined;
|
|
|
|
if (isMobile) {
|
|
// Mobile: a framed three-pane Element under the bottom tab bar breaks
|
|
// (nested scrolling, keyboard collapse, double chrome) — hand off to the
|
|
// standalone app instead (panel consensus: gateway pattern).
|
|
return (
|
|
<div className="flex h-full flex-col justify-center px-6 py-10 text-center">
|
|
<h1 className="text-lg font-bold">Newsroom chat</h1>
|
|
<p className="mx-auto mt-2 max-w-xs text-xs leading-relaxed text-text-dim">
|
|
Chat runs best as its own app on mobile — the full three-pane interface gets cramped inside the Hub frame.
|
|
</p>
|
|
<div className="mx-auto mt-8 flex w-full max-w-xs flex-col gap-3">
|
|
<a
|
|
href={identity.error === null ? `${elementOrigin}/` : FALLBACK_CHAT_URL}
|
|
className="flex min-h-12 items-center justify-center rounded-xl bg-accent px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
>
|
|
Open chat ↗
|
|
</a>
|
|
{ssoUser !== null && (
|
|
<a
|
|
href={`matrix:u/${ssoUser.slice(1)}`}
|
|
className="flex min-h-12 items-center justify-center rounded-xl border border-border bg-surface px-4 text-sm font-medium text-text transition-colors hover:border-accent"
|
|
>
|
|
Open in a Matrix app
|
|
</a>
|
|
)}
|
|
</div>
|
|
<p className="mt-4 text-2xs text-text-faint">
|
|
{ssoUser !== null ? `Signed in as ${ssoUser}` : "Sign in on desktop to link your account."}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<EmbeddedApp
|
|
title="Chat"
|
|
src={identity.error === null ? src : null}
|
|
externalHref={`${elementOrigin}/`}
|
|
busyLabel={busy}
|
|
error={identity.error ?? mintError}
|
|
notice={notice}
|
|
frameRef={frameRef}
|
|
onFrameLoad={handleFrameLoad}
|
|
onRetry={handleRetry}
|
|
allow={ELEMENT_ALLOW}
|
|
/>
|
|
);
|
|
}
|