Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ Loaded SPAs communicate with dotli through a postMessage-based protocol. The bri
| `navigateTo` | Opens URLs in new tabs |
| `featureSupported` | Reports whether a feature is supported (e.g. a chain's genesis hash) |
| `connectionStatus` | Streams auth state changes to the SPA |
| `chat.*` | Product chat: rooms and messages persisted locally, rendered in the topbar chat panel |

### Product chat

Products that declare `includes.chat` in their `worker.<label>.<tld>`
executable manifest get a Chat-kind TrUAPI execution and a chat button in
the topbar. The product drives the conversation over the core's chat
surface (`chat.create_room`, `chat.post_message`, `chat.list_subscribe`,
`chat.action_subscribe`); the user replies from the docked chat panel, and
each reply reaches the product as a `MessagePosted` action. Rooms and
messages persist in IndexedDB on the product origin and never leave the
device. The core denies chat calls without an active session, so chat
requires being logged in. The localhost debug paths enable chat
unconditionally so local products can be tested without publishing a
manifest.

### App iframe model

Expand Down
61 changes: 60 additions & 1 deletion apps/host/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// before main.ts executes. Overlaps IDB open (~10-50ms) with
// module loading. Picked up by src/db.ts via window.__dotliDb.
window.__dotliDb = new Promise(function(resolve, reject) {
var req = indexedDB.open("dotli", 2);
var req = indexedDB.open("dotli", 3);
req.onupgradeneeded = function() {
var db = req.result;
if (!db.objectStoreNames.contains("cids"))
Expand All @@ -32,6 +32,13 @@
}
if (!db.objectStoreNames.contains("notification_counters"))
db.createObjectStore("notification_counters", { keyPath: "productId" });
// v3: product chat rooms and messages.
if (!db.objectStoreNames.contains("chat_rooms"))
db.createObjectStore("chat_rooms", { keyPath: ["productId", "roomId"] });
if (!db.objectStoreNames.contains("chat_messages")) {
var c = db.createObjectStore("chat_messages", { keyPath: "seq", autoIncrement: true });
c.createIndex("byRoom", ["productId", "roomId"], { unique: false });
}
};
req.onsuccess = function() { resolve(req.result); };
req.onerror = function() { reject(new Error("IDB open failed")); };
Expand Down Expand Up @@ -87,6 +94,20 @@
<circle cx="12" cy="7" r="4"/>
</svg>
</button>
<button
id="chat-button"
class="topbar-btn"
title="Chat"
aria-label="Chat"
aria-expanded="false"
aria-controls="chat-panel"
hidden
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span class="chat-unread-badge" id="chat-unread-badge" hidden></span>
</button>
<button
id="permissions-button"
class="topbar-btn"
Expand Down Expand Up @@ -159,6 +180,12 @@
</span>
</button>
<div class="more-popover" id="more-popover">
<button class="more-row" id="more-row-chat" data-target="chat-button" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Chat</span>
</button>
<button class="more-row" data-target="permissions-button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
Expand Down Expand Up @@ -256,6 +283,38 @@ <h2 id="auth-modal-title">Login with Polkadot Mobile</h2>
<div class="permissions-popover-list" id="permissions-popover-list"></div>
</div>

<!-- Chat Panel (docked right; the panel runtime shrinks the product
iframe while open, mirroring the debug panel's right dock) -->
<aside class="chat-panel" id="chat-panel" role="complementary" aria-label="Product chat" hidden>
<div class="chat-panel-resize" id="chat-panel-resize" aria-hidden="true"></div>
<div class="chat-panel-header">
<span class="chat-panel-title" id="chat-panel-title">Chat</span>
<button class="chat-panel-close" id="chat-panel-close" title="Close chat" aria-label="Close chat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="chat-panel-rooms" id="chat-panel-rooms" role="tablist" aria-label="Chat rooms" hidden></div>
<div class="chat-panel-messages" id="chat-panel-messages" aria-live="polite"></div>
<p class="chat-panel-hint" id="chat-panel-hint" hidden></p>
<form class="chat-panel-composer" id="chat-panel-composer">
<input
id="chat-panel-input"
class="chat-panel-input"
type="text"
placeholder="Message"
autocomplete="off"
maxlength="4000"
/>
<button type="submit" class="chat-panel-send" id="chat-panel-send" title="Send" aria-label="Send">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</form>
</aside>

<!-- App Content -->
<div id="app">
<div class="loading">
Expand Down
26 changes: 26 additions & 0 deletions apps/host/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ import {
setActiveAppManifest,
setActiveRootManifest,
} from "@dotli/shared/active-manifest";
import {
primeChatCapability,
setChatCapability,
} from "@dotli/shared/chat-capability";
import type {
ExecutableManifest,
ManifestResult,
Expand Down Expand Up @@ -1028,6 +1032,9 @@ async function main(): Promise<void> {
urlBar.innerHTML = `<div class="topbar-url-pill localhost-pill" id="url-pill"><svg class="localhost-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg><span class="topbar-url-text"><span class="dot-domain">${escapeHtml(host)}</span></span></div>`;
}

// Local products carry no worker manifest to read the chat flag from,
// so the debug paths enable chat unconditionally for product testing.
setChatCapability(host, true);
const { renderIframe } = await bridgeModulePromise;
await renderIframe(previewTargetUrl, host, {
productId: productIdOverride,
Expand Down Expand Up @@ -1067,6 +1074,7 @@ async function main(): Promise<void> {
urlBar.innerHTML = `<div class="topbar-url-pill localhost-pill" id="url-pill"><svg class="localhost-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg><span class="topbar-url-text"><span class="dot-domain">${escapeHtml(host)}</span></span></div>`;
}

setChatCapability(host, true);
const { renderIframe } = await bridgeModulePromise;
await renderIframe(localhostUrl, host, { productId: productIdOverride });

Expand Down Expand Up @@ -1118,6 +1126,24 @@ async function main(): Promise<void> {

initScheduledNotifications({ label });

// Resolve the worker manifest's `includes.chat` in parallel with CID
// resolution. The bridge awaits this before creating the product's core
// provider (it decides the connection's execution kind), and the topbar
// uses the announced value to gate the chat button.
primeChatCapability(label, async () => {
const result =
chainBackend === "rpc-gateway"
? await (
await import("@dotli/resolver/rpc-resolve")
).resolveExecutableManifestViaRpc(label, "worker")
: await resolveExecutableManifestRemote(label, "worker");
return (
result.kind === "ok" &&
result.value.kind === "worker" &&
result.value.includes.chat
);
});

// Pre-load render chunk in parallel (overlap with CID resolution)
const renderChunkPromise: Promise<RenderChunk> = import("@dotli/ui/bridge");
void renderChunkPromise.catch(() => {
Expand Down
107 changes: 107 additions & 0 deletions packages/shared/src/chat-capability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright 2026 Parity Technologies (UK) Ltd.
// SPDX-License-Identifier: AGPL-3.0-only

// Whether the loaded product declares chat support, read from the worker
// executable manifest's `includes.chat` on `worker.<label>.<tld>`.
//
// The host shell primes this before rendering so the TrUAPI bridge can
// pick the product connection's execution kind ("Chat" vs "Spa") when it
// creates the provider, and the topbar can gate the chat button. The last
// resolved value is cached in localStorage per label so warm loads (cached
// CID) do not stall provider creation on a dotNS text-record read.

const CACHE_PREFIX = "dotli:chat-capable:";

/** Window event announcing a settled chat capability for a label. */
export const CHAT_AVAILABILITY_EVENT = "dotli:chat-availability";

export interface ChatAvailabilityDetail {
label: string;
chat: boolean;
}

let activeLabel: string | null = null;
let activePromise: Promise<boolean> | null = null;

function readCache(label: string): boolean | null {
try {
const raw = localStorage.getItem(`${CACHE_PREFIX}${label}`);
return raw === null ? null : raw === "1";
} catch {
return null;
}
}

function writeCache(label: string, value: boolean): void {
try {
localStorage.setItem(`${CACHE_PREFIX}${label}`, value ? "1" : "0");
// eslint-disable-next-line no-restricted-syntax -- localStorage may be unavailable (private mode); only the warm-start shortcut is lost.
} catch {
/* capability still resolves for this load */
}
}

function announce(label: string, chat: boolean): void {
if (typeof window === "undefined") {
return;
}
window.dispatchEvent(
new CustomEvent<ChatAvailabilityDetail>(CHAT_AVAILABILITY_EVENT, {
detail: { label, chat },
}),
);
}

/**
* Prime the capability for the product being rendered. The cached value
* answers immediately when present; `resolve` always runs to refresh the
* cache and re-announce, so a stale cache corrects itself on the next load.
*/
export function primeChatCapability(
label: string,
resolve: () => Promise<boolean>,
): void {
activeLabel = label;
const cached = readCache(label);
const fresh = resolve().then(
(value) => {
writeCache(label, value);
announce(label, value);
return value;
},
() => {
// An unreadable manifest means no chat this load; keep any cached
// value for the next one rather than overwriting it with a failure.
announce(label, cached ?? false);
return cached ?? false;
},
);
activePromise = cached === null ? fresh : Promise.resolve(cached);
if (cached !== null) {
announce(label, cached);
}
}

/** Force a known capability, used by the localhost product debug path. */
export function setChatCapability(label: string, chat: boolean): void {
activeLabel = label;
activePromise = Promise.resolve(chat);
announce(label, chat);
}

/**
* Capability for `label`, resolving `false` when nothing was primed or a
* different product is active.
*/
export function chatCapabilityFor(label: string): Promise<boolean> {
if (activeLabel !== label || activePromise === null) {
return Promise.resolve(false);
}
return activePromise;
}

/** Reset module state (tests only). */
export function resetChatCapabilityForTests(): void {
activeLabel = null;
activePromise = null;
}
80 changes: 80 additions & 0 deletions packages/shared/tests/chat-capability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2026 Parity Technologies (UK) Ltd.
// SPDX-License-Identifier: AGPL-3.0-only

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CHAT_AVAILABILITY_EVENT,
chatCapabilityFor,
primeChatCapability,
resetChatCapabilityForTests,
setChatCapability,
type ChatAvailabilityDetail,
} from "@dotli/shared/chat-capability";

function nextAnnouncement(): Promise<ChatAvailabilityDetail> {
return new Promise((resolve) => {
window.addEventListener(
CHAT_AVAILABILITY_EVENT,
(event) => {
resolve((event as CustomEvent<ChatAvailabilityDetail>).detail);
},
{ once: true },
);
});
}

describe("chat capability", () => {
beforeEach(() => {
localStorage.clear();
resetChatCapabilityForTests();
});

it("As the bridge, an unprimed label resolves to no chat", async () => {
expect(await chatCapabilityFor("unknown")).toBe(false);
});

it("As the bridge, priming resolves from the manifest and announces", async () => {
const announced = nextAnnouncement();

primeChatCapability("myapp", () => Promise.resolve(true));

expect(await chatCapabilityFor("myapp")).toBe(true);
expect(await announced).toEqual({ label: "myapp", chat: true });
expect(await chatCapabilityFor("other")).toBe(false);
});

it("As the bridge, a cached value answers without waiting on the resolver", async () => {
localStorage.setItem("dotli:chat-capable:myapp", "1");
const resolver = vi.fn(() => new Promise<boolean>(() => undefined));

primeChatCapability("myapp", resolver);

expect(await chatCapabilityFor("myapp")).toBe(true);
expect(resolver).toHaveBeenCalledOnce();
});

it("As the bridge, a fresh resolve updates the cache for the next load", async () => {
const announced = nextAnnouncement();
primeChatCapability("myapp", () => Promise.resolve(true));
await announced;

expect(localStorage.getItem("dotli:chat-capable:myapp")).toBe("1");
});

it("As the bridge, a failed resolve falls back to the cached value", async () => {
localStorage.setItem("dotli:chat-capable:myapp", "1");
const announced = nextAnnouncement();

primeChatCapability("myapp", () => Promise.reject(new Error("offline")));

expect(await chatCapabilityFor("myapp")).toBe(true);
expect(await announced).toEqual({ label: "myapp", chat: true });
expect(localStorage.getItem("dotli:chat-capable:myapp")).toBe("1");
});

it("As the debug path, a forced capability answers immediately", async () => {
setChatCapability("localhost:5173", true);

expect(await chatCapabilityFor("localhost:5173")).toBe(true);
});
});
Loading
Loading