From e86d8f2c2cf081dc08a3110bec37852eda995629 Mon Sep 17 00:00:00 2001 From: Caolan McMahon Date: Mon, 8 Jul 2024 21:01:23 +0100 Subject: [PATCH] examples/chat: break up into separate components --- examples/chat/components/header.js | 31 ++++ examples/chat/components/message-history.js | 48 ++++++ examples/chat/components/peers.js | 62 +++++++ examples/chat/components/send-message-form.js | 66 ++++++++ examples/chat/index.html | 14 +- examples/chat/{ => lib}/events.js | 6 + examples/chat/{ => lib}/signaller.js | 0 examples/chat/main.js | 160 ++---------------- examples/chat/state.js | 24 +++ examples/chat/style.css | 6 +- 10 files changed, 262 insertions(+), 155 deletions(-) create mode 100644 examples/chat/components/header.js create mode 100644 examples/chat/components/message-history.js create mode 100644 examples/chat/components/peers.js create mode 100644 examples/chat/components/send-message-form.js rename examples/chat/{ => lib}/events.js (70%) rename examples/chat/{ => lib}/signaller.js (100%) create mode 100644 examples/chat/state.js diff --git a/examples/chat/components/header.js b/examples/chat/components/header.js new file mode 100644 index 0000000..9817f43 --- /dev/null +++ b/examples/chat/components/header.js @@ -0,0 +1,31 @@ +import {watch} from "../lib/signaller.js"; +import {local_peer_id} from "../state.js"; + +export default class ChatHeader extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + const shadow = this.attachShadow({mode: "open"}); + shadow.innerHTML = ` + +
+

Chat Example

+ +
+ `; + const setText = () => { + const span = shadow.getElementById('local-peer-id'); + span.textContent = `You: ${local_peer_id.value}`; + }; + setText(); + this.stop = watch([local_peer_id], setText); + } + + disconnectedCallback() { + this.stop(); + } +} + +customElements.define("chat-header", ChatHeader); diff --git a/examples/chat/components/message-history.js b/examples/chat/components/message-history.js new file mode 100644 index 0000000..f4ac7f4 --- /dev/null +++ b/examples/chat/components/message-history.js @@ -0,0 +1,48 @@ +import {watch} from "../lib/signaller.js"; +import {local_peer_id, selected_invite, messages} from "../state.js"; + +export default class ChatMessageHistory extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.shadow = this.attachShadow({mode: "open"}); + this.shadow.innerHTML = ` + +

+        `;
+        this.history = this.shadow.getElementById('message-history');
+        this.stop = watch([local_peer_id, selected_invite, messages], () => {
+            this.updateMessages();
+        });
+        this.updateMessages();
+    }
+
+    disconnectedCallback() {
+        this.stop();
+    }
+
+    updateMessages() {
+        let txt = "";
+        if (selected_invite.value) {
+            for (const msg of messages.value) {
+                const match_from = (
+                    msg.from.peer === selected_invite.value.peer &&
+                    msg.from.app_instance_uuid === selected_invite.value.app_instance_uuid
+                );
+                const match_to = (
+                    msg.to.peer === selected_invite.value.peer &&
+                    msg.to.app_instance_uuid === selected_invite.value.app_instance_uuid
+                );
+                if (match_from || match_to) {
+                    const from = msg.from.peer === local_peer_id.value ? 'You' : msg.from.peer;
+                    txt += `<${from}>: ${msg.message}\n`;
+                }
+            }
+        }
+        this.history.textContent = txt;
+    }
+}
+
+customElements.define("chat-message-history", ChatMessageHistory);
diff --git a/examples/chat/components/peers.js b/examples/chat/components/peers.js
new file mode 100644
index 0000000..ff54d74
--- /dev/null
+++ b/examples/chat/components/peers.js
@@ -0,0 +1,62 @@
+import {watch} from "../lib/signaller.js";
+import {delegate} from "../lib/events.js";
+import {invites, selected_invite} from "../state.js";
+
+export default class ChatPeers extends HTMLElement {
+    constructor() {
+        super();
+    }
+
+    connectedCallback() {
+        this.shadow = this.attachShadow({mode: "open"});
+        this.shadow.innerHTML = `
+            
+            
+ `; + this.peers = this.shadow.getElementById('peers'); + this.cleanup = [ + watch([invites], () => this.updatePeers()), + watch([selected_invite], () => this.updateSelected()), + delegate(this.peers, "click", "#peers li", function () { + selected_invite.value = JSON.parse(this.dataset.invite); + }), + ]; + this.updatePeers(); + } + + disconnectedCallback() { + for (const destroy of this.cleanup) destroy(); + } + + updatePeers() { + this.peers.innerHTML = ''; + if (invites.value.length === 0) { + const span = document.createElement('span'); + span.textContent = "No peers discovered yet"; + this.peers.appendChild(span); + } else { + const ul = document.createElement('ul'); + for (const invite of invites.value) { + const li = document.createElement('li'); + li.textContent = invite.peer; + li.dataset.invite = JSON.stringify(invite); + ul.appendChild(li); + } + this.peers.appendChild(ul); + } + this.updateSelected(); + } + + updateSelected() { + const json = JSON.stringify(selected_invite.value); + for (const li of this.peers.querySelectorAll("li")) { + if (li.dataset.invite === json) { + li.classList.add("active"); + } else { + li.classList.remove("active"); + } + } + } +} + +customElements.define("chat-peers", ChatPeers); diff --git a/examples/chat/components/send-message-form.js b/examples/chat/components/send-message-form.js new file mode 100644 index 0000000..eeaf200 --- /dev/null +++ b/examples/chat/components/send-message-form.js @@ -0,0 +1,66 @@ +import {bind} from "../lib/events.js"; +import {watch} from "../lib/signaller.js"; +import {appendMessage, local_peer_id, local_app_uuid, selected_invite} from "../state.js"; + +export default class ChatSendMessageForm extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.shadow = this.attachShadow({mode: "open"}); + this.shadow.innerHTML = ` + +
+ + +
+ `; + this.form = this.shadow.getElementById('send-message-form'); + this.input = this.shadow.querySelector('[name=message]'); + this.cleanup = [ + bind(this.form, 'submit', ev => this.submit(ev)), + watch([selected_invite], () => this.updateVisibility()), + ]; + } + + disconnectedCallback() { + for (const destroy of this.cleanup) destroy(); + } + + updateVisibility() { + if (selected_invite.value) { + this.form.style.display = 'flex'; + this.input.focus(); + } else { + this.form.style.display = 'none'; + } + } + + async submit(ev) { + ev.preventDefault(); + if (selected_invite.value && local_peer_id.value && local_app_uuid.value) { + const message = this.input.value; + await fetch("/_api/v1/message_send", { + method: "POST", + body: JSON.stringify({ + peer: selected_invite.value.peer, + app_instance_uuid: selected_invite.value.app_instance_uuid, + message, + }) + }); + this.input.value = ""; + const from = { + peer: local_peer_id.value, + app_instance_uuid: local_app_uuid.value, + }; + const to = { + peer: selected_invite.value.peer, + app_instance_uuid: selected_invite.value.app_instance_uuid, + }; + appendMessage(from, to, message); + } + } +} + +customElements.define("chat-send-message-form", ChatSendMessageForm); diff --git a/examples/chat/index.html b/examples/chat/index.html index 838289e..aed79f7 100644 --- a/examples/chat/index.html +++ b/examples/chat/index.html @@ -8,18 +8,12 @@ -
-

Chat example

- -
+
-
+
-

-                
- - -
+ +
diff --git a/examples/chat/events.js b/examples/chat/lib/events.js similarity index 70% rename from examples/chat/events.js rename to examples/chat/lib/events.js index 2c5fb10..929e269 100644 --- a/examples/chat/events.js +++ b/examples/chat/lib/events.js @@ -13,3 +13,9 @@ export function delegate(node, event_name, selector, listener, options) { // Return undelegate function return () => node.removeEventListener(event_name, fn); } + +export function bind(node, event_name, listener, options) { + node.addEventListener(event_name, listener, options); + // Return unbind function + return () => node.removeEventListener(event_name, listener); +} diff --git a/examples/chat/signaller.js b/examples/chat/lib/signaller.js similarity index 100% rename from examples/chat/signaller.js rename to examples/chat/lib/signaller.js diff --git a/examples/chat/main.js b/examples/chat/main.js index eda265d..1598aeb 100644 --- a/examples/chat/main.js +++ b/examples/chat/main.js @@ -1,21 +1,14 @@ // @ts-check -import {Signaller, watch} from "./signaller.js"; -import {delegate} from "./events.js"; +import * as state from "./state.js"; -/** @typedef {{peer: string, uuid: string}} Invite */ -/** @typedef {{peer: string, app_instance_uuid: string}} AppInstance */ -/** @typedef {{message: string, from: AppInstance, to: AppInstance}} Message */ - -// State -const local_peer_id = new Signaller(/** @type {null | string} */(null)); -const local_app_uuid = new Signaller(/** @type {null | string} */(null)); -const selected_invite = new Signaller(/** @type {null | AppInstance} */(null)); -const peers = new Signaller(new Set()); -const messages = new Signaller(/** @type {Message[]} */([])); -const invites = new Signaller(/** @type {Invite[]} */([])); +// Custom elements +import "./components/header.js"; +import "./components/peers.js"; +import "./components/message-history.js"; +import "./components/send-message-form.js"; async function updateLocalPeerId() { - local_peer_id.value = await fetch("/_api/v1/local_peer_id").then( + state.local_peer_id.value = await fetch("/_api/v1/local_peer_id").then( res => res.text() ); } @@ -24,67 +17,48 @@ async function updateLocalAppInstance() { const data = await fetch("/_api/v1/application_instance").then( res => res.json() ); - local_app_uuid.value = data.uuid; -} - -/** @param {{peer: string, uuid: string}} invite */ -function renderInvite(invite) { - const li = document.createElement('li'); - li.textContent = invite.peer; - li.dataset.invite = JSON.stringify(invite); - return li; + state.local_app_uuid.value = data.uuid; } -/** @param {{peer: string, uuid: string}[]} invites */ async function updatePeers() { const res = await fetch("/_api/v1/peers"); const data = new Set(await res.json()); // Send invites to newly discovered peers - const new_peers = data.difference(peers.value); + const new_peers = data.difference(state.peers.value); for (const peer of new_peers) { await fetch("/_api/v1/message_invite", { method: 'POST', body: JSON.stringify({peer}), }); } - peers.value = data; + state.peers.value = data; } async function updateInvites() { const res = await fetch("/_api/v1/message_invites"); const data = /** @type {{peer: string, uuid: string}[]} */(await res.json()); // Only list invites for peers in current discovered list - const new_invites = data.filter(x => peers.value.has(x.peer)); + const new_invites = data.filter(x => state.peers.value.has(x.peer)); // Update state only if invites have changed - if (JSON.stringify(new_invites) !== JSON.stringify(invites.value)) { - invites.value = new_invites; + if (JSON.stringify(new_invites) !== JSON.stringify(state.invites.value)) { + state.invites.value = new_invites; } } -/** - * @param {AppInstance} from - * @param {AppInstance} to - * @param {string} message - */ -function appendMessage(from, to, message) { - messages.value.push({from, to, message}); - messages.signal(); -} - async function getMessages() { while (true) { const res = await fetch("/_api/v1/message_read"); const data = await res.json(); - if (data && local_peer_id.value && local_app_uuid.value) { + if (data && state.local_peer_id.value && state.local_app_uuid.value) { const from = { peer: data.peer, app_instance_uuid: data.uuid, }; const to = { - peer: local_peer_id.value, - app_instance_uuid: local_app_uuid.value, + peer: state.local_peer_id.value, + app_instance_uuid: state.local_app_uuid.value, }; - appendMessage(from, to, data.message); + state.appendMessage(from, to, data.message); await fetch("/_api/v1/message_next", {method: "POST"}); } else { // Check again in 1 second @@ -94,106 +68,8 @@ async function getMessages() { } } -const form = document.getElementById('send-message-form'); -const input = form.querySelector('input[type=text]'); - -form.addEventListener('submit', /** @param {SubmitEvent} ev */async ev => { - ev.preventDefault(); - if (selected_invite.value && local_peer_id.value && local_app_uuid.value) { - const message = input.value; - await fetch("/_api/v1/message_send", { - method: "POST", - body: JSON.stringify({ - peer: selected_invite.value.peer, - app_instance_uuid: selected_invite.value.app_instance_uuid, - message, - }) - }); - input.value = ""; - const from = { - peer: local_peer_id.value, - app_instance_uuid: local_app_uuid.value, - }; - const to = { - peer: selected_invite.value.peer, - app_instance_uuid: selected_invite.value.app_instance_uuid, - }; - appendMessage(from, to, message); - } -}); - -delegate(document.body, "click", "#peers li", /** @this {HTMLLIElement} */function () { - selected_invite.value = JSON.parse(this.dataset.invite); -}); - -function renderLocalPeerId() { - const el = document.getElementById('local-peer-id'); - el.textContent = `You: ${local_peer_id.value}`; -} - -function renderSelectedInvite() { - const json = JSON.stringify(selected_invite.value); - for (const li of document.querySelectorAll("#peers li")) { - if (li.dataset.invite === json) { - li.classList.add("active"); - } else { - li.classList.remove("active"); - } - } - if (selected_invite.value) { - form.style.display = 'flex'; - input.focus(); - } else { - form.style.display = 'none'; - } -} - -function renderMessages() { - const el = document.getElementById('message-history'); - let txt = ""; - if (selected_invite.value) { - for (const msg of messages.value) { - const match_from = ( - msg.from.peer === selected_invite.value.peer && - msg.from.app_instance_uuid === selected_invite.value.app_instance_uuid - ); - const match_to = ( - msg.to.peer === selected_invite.value.peer && - msg.to.app_instance_uuid === selected_invite.value.app_instance_uuid - ); - if (match_from || match_to) { - const from = msg.from.peer === local_peer_id.value ? 'You' : msg.from.peer; - txt += `<${from}>: ${msg.message}\n`; - } - } - } - el.textContent = txt; -} - -function renderInvites() { - const el = document.getElementById('peers'); - el.innerHTML = ''; - if (invites.value.length === 0) { - const span = document.createElement('span'); - span.textContent = "No peers discovered yet"; - el.appendChild(span); - } else { - const ul = document.createElement('ul'); - for (const invite of invites.value) { - ul.appendChild(renderInvite(invite)); - } - el.appendChild(ul); - } -} - -// Update UI when state changes -watch([selected_invite], renderSelectedInvite); -watch([local_peer_id], renderLocalPeerId); -watch([local_peer_id, selected_invite, messages], renderMessages); -watch([invites], renderInvites); - // Initialize example app -renderInvites(); +// renderInvites(); await updateLocalPeerId(); await updateLocalAppInstance(); await updatePeers(); diff --git a/examples/chat/state.js b/examples/chat/state.js new file mode 100644 index 0000000..2e0ab3c --- /dev/null +++ b/examples/chat/state.js @@ -0,0 +1,24 @@ +// @ts-check +import {Signaller} from "./lib/signaller.js"; + +/** @typedef {{peer: string, uuid: string}} Invite */ +/** @typedef {{peer: string, app_instance_uuid: string}} AppInstance */ +/** @typedef {{message: string, from: AppInstance, to: AppInstance}} Message */ + +export const local_peer_id = new Signaller(/** @type {null | string} */(null)); +export const local_app_uuid = new Signaller(/** @type {null | string} */(null)); +export const selected_invite = new Signaller(/** @type {null | AppInstance} */(null)); +export const peers = new Signaller(new Set()); +export const messages = new Signaller(/** @type {Message[]} */([])); +export const invites = new Signaller(/** @type {Invite[]} */([])); + +/** + * @param {AppInstance} from + * @param {AppInstance} to + * @param {string} message + */ +export function appendMessage(from, to, message) { + messages.value.push({from, to, message}); + messages.signal(); +} + diff --git a/examples/chat/style.css b/examples/chat/style.css index 19c7ec3..1c6e800 100644 --- a/examples/chat/style.css +++ b/examples/chat/style.css @@ -27,14 +27,14 @@ main { flex-grow: 1; } -#peers, #messages { +chat-peers, #messages { padding: 0; margin: 0; box-sizing: border-box; min-height: 100%; } -#peers { +chat-peers { padding: 1em; border-right: 1px solid #ccc; width: 20%; @@ -67,7 +67,7 @@ main { flex-shrink: 1; } -#message-history { +chat-message-history { flex-grow: 1; flex-shrink: 1; padding: 1em;