diff --git a/ui/system/index.js b/ui/system/index.js
index 1843f18..bad50ca 100644
--- a/ui/system/index.js
+++ b/ui/system/index.js
@@ -3,6 +3,7 @@
import { WebView } from "./web_view.js";
import { LayoutManager } from "./layout_manager.js";
import { MobileLayoutManager } from "./mobile_layout_manager.js";
+import { PairingHandler } from "./p2p.js";
import "./system_menu.js";
import "./mobile_action_bar.js";
import "./mobile_notification_sheet.js";
@@ -173,141 +174,12 @@ function clearAllNotifications() {
updateNotificationBadge();
}
-// P2P pairing request handling
-const pendingPairingRequests = new Map(); // id -> peer
-const pairingApi = navigator.embedder.pairing;
-
-pairingApi.addEventListener("peerdiscovered", (e) => {
- const name = e.peer.displayName || e.peer.id;
- toastManager?.add(`${name} appeared`);
-});
-
-pairingApi.addEventListener("pairingrequest", (e) => {
- const peer = e.peer;
- console.log(`[P2P] Pairing request from ${peer.displayName} (${peer.id})`);
- pendingPairingRequests.set(peer.id, peer);
-
- addNotification({
- title: "Pairing Request",
- body: `${peer.displayName || peer.id} wants to pair`,
- tag: "pairing-requests",
- iconUrl: "",
- });
+// P2P pairing handler
+const pairingHandler = new PairingHandler({
+ onNotificationAdd: addNotification,
+ onNotificationRemove: removeNotificationByTag,
});
-// Clean up pending requests when pairing completes or the peer leaves.
-pairingApi.addEventListener("peerjoined", (e) => {
- pendingPairingRequests.delete(e.peer.id);
- if (pendingPairingRequests.size === 0) {
- removeNotificationByTag("pairing-requests");
- }
-});
-
-pairingApi.addEventListener("peerleft", (e) => {
- pendingPairingRequests.delete(e.peer.id);
- if (pendingPairingRequests.size === 0) {
- removeNotificationByTag("pairing-requests");
- }
-});
-
-function showPairingDialog() {
- // Close any existing dialog
- const existing = document.getElementById("pairing-dialog");
- if (existing) {
- existing.close();
- existing.remove();
- }
-
- if (pendingPairingRequests.size === 0) return;
-
- const dialog = document.createElement("dialog");
- dialog.id = "pairing-dialog";
- dialog.innerHTML = `
-
Pairing Requests
-
- ${Array.from(pendingPairingRequests.entries()).map(([id, peer]) => `
-
-
-
${peer.displayName || id}
-
${id}
-
-
-
-
-
-
- `).join("")}
-
-
- `;
-
- document.body.appendChild(dialog);
-
- // Close on backdrop click
- dialog.addEventListener("click", (e) => {
- if (e.target === dialog) dialog.close();
- });
- dialog.addEventListener("close", () => dialog.remove());
-
- dialog.querySelector(".btn-close-dialog").addEventListener("click", () => {
- dialog.close();
- });
-
- dialog.querySelectorAll(".btn-accept").forEach((btn) => {
- btn.addEventListener("click", async () => {
- const id = btn.dataset.id;
- const peer = pendingPairingRequests.get(id);
- if (!peer) return;
- btn.disabled = true;
- btn.textContent = "Accepting...";
- try {
- await pairingApi.acceptPairing(peer);
- console.log(`[P2P] Accepted pairing from ${id}`);
- pendingPairingRequests.delete(id);
- updatePairingDialog(dialog);
- } catch (e) {
- console.error(`[P2P] Accept failed:`, e);
- btn.textContent = "Failed";
- }
- });
- });
-
- dialog.querySelectorAll(".btn-reject").forEach((btn) => {
- btn.addEventListener("click", async () => {
- const id = btn.dataset.id;
- const peer = pendingPairingRequests.get(id);
- if (!peer) return;
- btn.disabled = true;
- btn.textContent = "Rejecting...";
- try {
- await pairingApi.rejectPairing(peer);
- console.log(`[P2P] Rejected pairing from ${id}`);
- pendingPairingRequests.delete(id);
- updatePairingDialog(dialog);
- } catch (e) {
- console.error(`[P2P] Reject failed:`, e);
- btn.textContent = "Failed";
- }
- });
- });
-
- try {
- dialog.showModal();
- } catch (e) {
- console.error(`[P2P] showModal() failed:`, e);
- }
-}
-
-function updatePairingDialog(dialog) {
- if (pendingPairingRequests.size === 0) {
- dialog.close();
- removeNotificationByTag("pairing-requests");
- } else {
- dialog.close();
- showPairingDialog();
- }
-}
-
window.servo = {
/**
* Called by Servo when an embedded webview has opened a new embedded webview
@@ -792,7 +664,9 @@ document.addEventListener("DOMContentLoaded", () => {
// Handle pairing request notifications specially
if (notification.tag === "pairing-requests") {
mobileNotificationSheet.open = false;
- if (pendingPairingRequests.size > 0) showPairingDialog();
+ if (pairingHandler.hasPendingRequests) {
+ pairingHandler.showPairingDialog();
+ }
return;
}
@@ -986,7 +860,9 @@ document.addEventListener("DOMContentLoaded", () => {
// Handle pairing request notifications specially
if (notification.tag === "pairing-requests") {
notificationPanel.open = false;
- if (pendingPairingRequests.size > 0) showPairingDialog();
+ if (pairingHandler.hasPendingRequests) {
+ pairingHandler.showPairingDialog();
+ }
return;
}
diff --git a/ui/system/p2p.js b/ui/system/p2p.js
new file mode 100644
index 0000000..4d516ad
--- /dev/null
+++ b/ui/system/p2p.js
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+/**
+ * Handles P2P pairing events: peer discovery toasts, incoming pairing
+ * requests, and the accept/reject dialog.
+ */
+export class PairingHandler {
+ #api = navigator.embedder.pairing;
+ #pending = new Map(); // id -> peer
+ #onNotificationAdd;
+ #onNotificationRemove;
+
+ /**
+ * @param {object} opts
+ * @param {(notification: object) => void} opts.onNotificationAdd
+ * @param {(tag: string) => void} opts.onNotificationRemove
+ */
+ constructor({ onNotificationAdd, onNotificationRemove }) {
+ this.#onNotificationAdd = onNotificationAdd;
+ this.#onNotificationRemove = onNotificationRemove;
+
+ this.#api.addEventListener("peerdiscovered", (e) => {
+ const name = e.peer.displayName || e.peer.id;
+ toastManager?.add(`${name} appeared`);
+ });
+
+ this.#api.addEventListener("pairingrequest", (e) => {
+ const peer = e.peer;
+ console.log(
+ `[P2P] Pairing request from ${peer.displayName} (${peer.id})`,
+ );
+ this.#pending.set(peer.id, peer);
+ this.#onNotificationAdd({
+ title: "Pairing Request",
+ body: `${peer.displayName || peer.id} wants to pair`,
+ tag: "pairing-requests",
+ iconUrl: "",
+ });
+ });
+
+ this.#api.addEventListener("peerjoined", (e) => {
+ this.#clearPendingPeer(e.peer.id);
+ });
+
+ this.#api.addEventListener("peerleft", (e) => {
+ this.#clearPendingPeer(e.peer.id);
+ });
+ }
+
+ get hasPendingRequests() {
+ return this.#pending.size > 0;
+ }
+
+ showPairingDialog() {
+ // Close any existing dialog.
+ const existing = document.getElementById("pairing-dialog");
+ if (existing) {
+ existing.close();
+ existing.remove();
+ }
+
+ if (this.#pending.size === 0) return;
+
+ const dialog = document.createElement("dialog");
+ dialog.id = "pairing-dialog";
+ dialog.innerHTML = `
+ Pairing Requests
+
+ ${Array.from(this.#pending.entries())
+ .map(
+ ([id, peer]) => `
+
+
+
${peer.displayName || id}
+
${id}
+
+
+
+
+
+
+ `,
+ )
+ .join("")}
+
+
+ `;
+
+ document.body.appendChild(dialog);
+
+ dialog.addEventListener("click", (e) => {
+ if (e.target === dialog) dialog.close();
+ });
+ dialog.addEventListener("close", () => dialog.remove());
+
+ dialog
+ .querySelector(".btn-close-dialog")
+ .addEventListener("click", () => dialog.close());
+
+ dialog.querySelectorAll(".btn-accept").forEach((btn) => {
+ btn.addEventListener("click", async () => {
+ const id = btn.dataset.id;
+ const peer = this.#pending.get(id);
+ if (!peer) return;
+ btn.disabled = true;
+ btn.textContent = "Accepting...";
+ try {
+ await this.#api.acceptPairing(peer);
+ console.log(`[P2P] Accepted pairing from ${id}`);
+ this.#pending.delete(id);
+ this.#refreshDialog(dialog);
+ } catch (e) {
+ console.error(`[P2P] Accept failed:`, e);
+ btn.textContent = "Failed";
+ }
+ });
+ });
+
+ dialog.querySelectorAll(".btn-reject").forEach((btn) => {
+ btn.addEventListener("click", async () => {
+ const id = btn.dataset.id;
+ const peer = this.#pending.get(id);
+ if (!peer) return;
+ btn.disabled = true;
+ btn.textContent = "Rejecting...";
+ try {
+ await this.#api.rejectPairing(peer);
+ console.log(`[P2P] Rejected pairing from ${id}`);
+ this.#pending.delete(id);
+ this.#refreshDialog(dialog);
+ } catch (e) {
+ console.error(`[P2P] Reject failed:`, e);
+ btn.textContent = "Failed";
+ }
+ });
+ });
+
+ try {
+ dialog.showModal();
+ } catch (e) {
+ console.error(`[P2P] showModal() failed:`, e);
+ }
+ }
+
+ #refreshDialog(dialog) {
+ if (this.#pending.size === 0) {
+ dialog.close();
+ this.#onNotificationRemove("pairing-requests");
+ } else {
+ dialog.close();
+ this.showPairingDialog();
+ }
+ }
+
+ #clearPendingPeer(id) {
+ this.#pending.delete(id);
+ if (this.#pending.size === 0) {
+ this.#onNotificationRemove("pairing-requests");
+ }
+ }
+}
diff --git a/ui/system/toasts.css b/ui/system/toasts.css
index d750c72..0114ec2 100644
--- a/ui/system/toasts.css
+++ b/ui/system/toasts.css
@@ -10,7 +10,6 @@
flex-direction: column;
align-items: center;
pointer-events: none;
- padding-top: env(safe-area-inset-top, var(--spacing-sm));
}
.toast {
@@ -26,7 +25,9 @@
text-align: center;
transform: translateY(-100%);
opacity: 0;
- transition: transform 0.3s ease, opacity 0.3s ease;
+ transition:
+ transform 0.3s ease,
+ opacity 0.3s ease;
}
.toast.visible {
diff --git a/ui/system/toasts.js b/ui/system/toasts.js
index 0e99246..095a7e5 100644
--- a/ui/system/toasts.js
+++ b/ui/system/toasts.js
@@ -49,13 +49,13 @@ class ToastManager extends LitElement {
() => {
this._toasts = this._toasts.filter((t) => t.id !== id);
},
- { once: true }
+ { once: true },
);
}
render() {
return html`${this._toasts.map(
- (t) => html`${t.text}
`
+ (t) => html`${t.text}
`,
)}`;
}
}