diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 00000000..31c7c8fa Binary files /dev/null and b/bun.lockb differ diff --git a/dist/index.css b/dist/index.css new file mode 100644 index 00000000..fc07bd94 --- /dev/null +++ b/dist/index.css @@ -0,0 +1,24 @@ +/* src/styles.css */ +#settings-tab > div { + display: flex; + flex-direction: column; + gap: 2rem; +} +#settings-tab > div > div { + width: var(--max-input-width); + min-width: 100%; + max-width: 100%; + flex: 1; +} +article:not([connected]) .connected-only { + display: none; +} +article:not([connected]) .disconnected-only { + display: flex; +} +article[connected] .connected-only { + display: flex; +} +article[connected] .disconnected-only { + display: none; +} diff --git a/dist/index.html b/dist/index.html index e8c5f257..6aac5b92 100644 --- a/dist/index.html +++ b/dist/index.html @@ -21,6 +21,7 @@ /> + diff --git a/dist/index.js b/dist/index.js index 98ce4422..e7e41069 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,5 +1,159 @@ (() => { + // node_modules/uuid/dist/esm-browser/stringify.js + var byteToHex = []; + for (i = 0; i < 256; ++i) { + byteToHex.push((i + 256).toString(16).slice(1)); + } + var i; + function unsafeStringify(arr, offset = 0) { + return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase(); + } + + // node_modules/uuid/dist/esm-browser/rng.js + var getRandomValues; + var rnds8 = new Uint8Array(16); + function rng() { + if (!getRandomValues) { + getRandomValues = typeof crypto !== "undefined" && crypto.getRandomValues && crypto.getRandomValues.bind(crypto); + if (!getRandomValues) { + throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported"); + } + } + return getRandomValues(rnds8); + } + + // node_modules/uuid/dist/esm-browser/native.js + var randomUUID = typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID.bind(crypto); + var native_default = { + randomUUID + }; + + // node_modules/uuid/dist/esm-browser/v4.js + function v4(options, buf, offset) { + if (native_default.randomUUID && !buf && !options) { + return native_default.randomUUID(); + } + options = options || {}; + var rnds = options.random || (options.rng || rng)(); + rnds[6] = rnds[6] & 15 | 64; + rnds[8] = rnds[8] & 63 | 128; + if (buf) { + offset = offset || 0; + for (var i = 0; i < 16; ++i) { + buf[offset + i] = rnds[i]; + } + return buf; + } + return unsafeStringify(rnds); + } + var v4_default = v4; + // node_modules/bloatless-react/index.ts + function UUID() { + return v4_default(); + } + var State = class { + _value; + _bindings = /* @__PURE__ */ new Set(); + // init + constructor(initialValue) { + this._value = initialValue; + } + // value + get value() { + return this._value; + } + set value(newValue) { + if (this._value == newValue) return; + this._value = newValue; + this.callSubscriptions(); + } + // subscriptions + callSubscriptions() { + this._bindings.forEach((fn) => fn(this._value)); + } + subscribe(fn) { + this._bindings.add(fn); + fn(this._value); + } + // stringify + toString() { + return JSON.stringify(this._value); + } + }; + var ListState = class extends State { + additionHandlers = /* @__PURE__ */ new Set(); + removalHandlers = /* @__PURE__ */ new Map(); + // init + constructor(initialItems) { + super(new Set(initialItems)); + } + // list + add(...items) { + items.forEach((item) => { + this.value.add(item); + this.additionHandlers.forEach((handler) => handler(item)); + }); + this.callSubscriptions(); + } + remove(...items) { + items.forEach((item) => { + this.value.delete(item); + const id = item.id; + if (!this.removalHandlers.has(id)) return; + this.removalHandlers.get(id)(item); + this.removalHandlers.delete(id); + }); + this.callSubscriptions(); + } + // handlers + handleAddition(handler) { + this.additionHandlers.add(handler); + [...this.value.values()].forEach(handler); + } + handleRemoval(item, handler) { + this.removalHandlers.set(item.id, handler); + } + // stringification + toString() { + const array = [...this.value]; + const json = JSON.stringify(array); + return json; + } + }; + function createProxyState(statesToSubscibe, fn) { + const proxyState = new State(fn()); + statesToSubscibe.forEach( + (state) => state.subscribe(() => proxyState.value = fn()) + ); + return proxyState; + } + function persistState(localStorageKey, state) { + state.subscribe(() => { + const stringifiedValue = state.toString(); + localStorage.setItem(localStorageKey, stringifiedValue); + }); + } + function restoreState(localStorageKey, initialStateValue) { + const storedString = localStorage.getItem(localStorageKey) ?? JSON.stringify(initialStateValue); + const convertedValue = JSON.parse(storedString); + const state = new State(convertedValue); + persistState(localStorageKey, state); + return state; + } + function restoreListState(localStorageKey) { + const storedString = localStorage.getItem(localStorageKey) ?? ""; + let initialItems = []; + try { + const array = JSON.parse(storedString); + if (!Array.isArray(array)) throw ""; + initialItems = array; + } catch { + } + const state = new ListState(initialItems); + persistState(localStorageKey, state); + return state; + } function createElement(tagName, attributes = {}, ...children) { const element = document.createElement(tagName); if (attributes != null) @@ -78,37 +232,391 @@ return element; } + // node_modules/udn-frontend/index.ts + var UDNFrontend = class { + ws; + // handlers + connectionHandler = () => { + }; + disconnectionHandler = () => { + }; + messageHandler = (data) => { + }; + // init + set onconnect(handler) { + this.connectionHandler = handler; + } + set ondisconnect(handler) { + this.disconnectionHandler = handler; + } + set onmessage(handler) { + this.messageHandler = handler; + } + // utility methods + send(messageObject) { + if (this.ws == void 0) return false; + const messageString = JSON.stringify(messageObject); + this.ws.send(messageString); + return true; + } + // public methods + connect(address) { + this.ws = new WebSocket(address); + this.ws.addEventListener("open", this.connectionHandler); + this.ws.addEventListener("close", this.disconnectionHandler); + this.ws.addEventListener("message", (message) => { + const dataString = message.data.toString(); + const data = JSON.parse(dataString); + this.messageHandler(data); + }); + } + sendMessage(channel, body) { + const messageObject = { + messageChannel: channel, + messageBody: body + }; + return this.send(messageObject); + } + subscribe(channel) { + const messageObject = { + subscribeChannel: channel + }; + return this.send(messageObject); + } + unsubscribe(channel) { + const messageObject = { + unsubscribeChannel: channel + }; + return this.send(messageObject); + } + }; + + // src/model.ts + var UDN = new UDNFrontend(); + var Channel = class { + constructor(channelName) { + this.channelName = channelName; + } + id = UUID(); + }; + var Message = class { + constructor(channel, sender, body, isoDate) { + this.channel = channel; + this.sender = sender; + this.body = body; + this.isoDate = isoDate; + } + id = UUID(); + }; + var currentAddress = new State(""); + var serverAddress = restoreState("socket-address", ""); + var isConnected = new State(false); + var cannotConnect = createProxyState( + [serverAddress, isConnected], + () => serverAddress.value == "" || currentAddress.value == serverAddress.value && isConnected.value == true + ); + var currentPrimaryChannel = new State(""); + var primaryChannel = restoreState("primary-channel", ""); + var newSecondaryChannelName = new State(""); + var secondaryChannels = restoreListState("secondary-channels"); + var encryptionKey = restoreState("encryption-key", ""); + var senderName = restoreState("sender-name", ""); + var messageBody = restoreState("message", ""); + var cannotSetChannel = createProxyState( + [primaryChannel, currentPrimaryChannel, isConnected], + () => primaryChannel.value == "" || primaryChannel.value == currentPrimaryChannel.value || isConnected.value == false + ); + var cannotAddSecondaryChannel = createProxyState( + [newSecondaryChannelName], + () => newSecondaryChannelName.value == "" + ); + var cannotSendMessage = createProxyState( + [currentPrimaryChannel, messageBody, senderName], + () => currentPrimaryChannel.value == "" || messageBody.value == "" || senderName.value == "" + ); + var messages = restoreListState("messages"); + function connect() { + if (cannotConnect.value == true) return; + currentAddress.value = serverAddress.value; + UDN.connect(serverAddress.value); + } + function sendMessage() { + if (cannotSendMessage.value == true) return; + const secondaryChannelNames = [ + ...secondaryChannels.value.values() + ].map((channel) => channel.channelName); + const allChannelNames = [ + primaryChannel.value, + ...secondaryChannelNames + ]; + const joinedChannelName = allChannelNames.join("/"); + const messageObject = new Message( + joinedChannelName, + senderName.value, + messageBody.value, + (/* @__PURE__ */ new Date()).toISOString() + ); + const messageString = JSON.stringify(messageObject); + UDN.sendMessage(joinedChannelName, messageString); + messageBody.value = ""; + } + function setChannel() { + if (cannotSetChannel.value == true) return; + if (currentPrimaryChannel != void 0) { + UDN.unsubscribe(currentPrimaryChannel.value); + } + UDN.subscribe(primaryChannel.value); + } + function addSecondaryChannel() { + if (cannotAddSecondaryChannel.value == true) return; + const channelObject = new Channel(newSecondaryChannelName.value); + secondaryChannels.add(channelObject); + newSecondaryChannelName.value = ""; + } + function removeSecondaryChannel(channel) { + secondaryChannels.remove(channel); + } + UDN.onconnect = () => { + isConnected.value = true; + }; + UDN.onmessage = (data) => { + if (data.subscribed != void 0 && data.messageChannel != void 0) { + return handleSubscriptionConfirmation(data.messageChannel, data.subscribed); + } else if (data.messageBody) { + handleMessage(data.messageBody); + } + }; + UDN.ondisconnect = () => { + isConnected.value = false; + }; + function handleSubscriptionConfirmation(channel, isSubscribed) { + console.log(channel, isSubscribed); + if (isSubscribed == true) { + currentPrimaryChannel.value = channel; + } + } + function handleMessage(body) { + try { + const object = JSON.parse(body); + if (!object.channel || !object.sender || !object.body || !object.isoDate) + return; + const messageObject = new Message( + object.channel, + object.sender, + object.body, + object.isoDate + ); + messages.add(messageObject); + } catch { + } + } + // src/translations.ts var englishTranslations = { + // general + setInput: "Set", + connectionStatus: "Connection status", + connectedTo: createProxyState( + [currentAddress], + () => `Connected to ${currentAddress}` + ), + disconnected: "Disconnected", // settings settings: "Settings", + connection: "Connection", + communication: "Communication", + encryption: "Encryption", + serverAddress: "Server Address", + serverAddressPlaceholder: "ws://192.168.0.69:3000", + connectToServer: "Connect", + primaryChannel: "Primary channel", + channelPlaceholder: "my-channel", + addSecondaryChannel: "Add secondary channel", + removeSecondaryChannel: "Remove secondary channel", + newSecondaryChannelPlaceholder: "Add secondary channel", + encryptionKey: "Encryption key", + encryptionKeyPlaceholder: "1jg028ej40d", + showEncryptionKey: "Show encryption key", + yourName: "Your Name", + yourNamePlaceholder: "Jane Doe", // messages - messages: "Messages" + messages: "Messages", + composerPlaceholder: "Type a message...", + sendMessage: "Send" }; - var translations = { + var allTranslations = { en: englishTranslations }; - function getText(key) { - const language = navigator.language.substring(0, 2); - if (translations[language]) { - return translations[language][key]; - } - return translations.en[key]; + var language = navigator.language.substring(0, 2); + var translation = allTranslations[language] ?? allTranslations.en; + + // src/Views/messageComposer.tsx + function MessageComposer() { + return /* @__PURE__ */ createElement("div", { class: "flex-row width-100" }, /* @__PURE__ */ createElement( + "input", + { + class: "width-100 flex-1", + style: "max-width: unset", + placeholder: translation.composerPlaceholder, + "bind:value": messageBody, + "on:enter": sendMessage + } + ), /* @__PURE__ */ createElement( + "button", + { + class: "primary", + "on:click": sendMessage, + "toggle:disabled": cannotSendMessage + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "send") + )); } - // src/messageTab.tsx + // src/Views/threadView.tsx + var messageConverter = (message) => { + return /* @__PURE__ */ createElement("div", { class: "tile width-100 flex-no" }, /* @__PURE__ */ createElement("div", { class: "flex-column" }, /* @__PURE__ */ createElement("span", { class: "secondary" }, message.sender, "@", message.channel), /* @__PURE__ */ createElement("b", null, message.body), /* @__PURE__ */ createElement("span", { class: "secondary" }, new Date(message.isoDate).toLocaleString()))); + }; + function ThreadView() { + return /* @__PURE__ */ createElement("div", { class: "flex-column gap", "subscribe:children": [messages, messageConverter] }); + } + + // src/Tabs/messageTab.tsx function MessageTab() { - return /* @__PURE__ */ createElement("article", { id: "message-tab" }, /* @__PURE__ */ createElement("header", null, getText("messages"))); + return /* @__PURE__ */ createElement("article", { id: "message-tab" }, /* @__PURE__ */ createElement("header", null, translation.messages), ThreadView(), /* @__PURE__ */ createElement("footer", null, MessageComposer())); } - // src/settingsTab.tsx + // src/Views/communicationSection.tsx + var secondaryChannelConverter = (channel) => { + function remove() { + removeSecondaryChannel(channel); + } + return /* @__PURE__ */ createElement("div", { class: "tile padding-0" }, /* @__PURE__ */ createElement("div", { class: "flex-row justify-apart align-center" }, /* @__PURE__ */ createElement("b", { class: "padding-h" }, channel.channelName), /* @__PURE__ */ createElement( + "button", + { + class: "danger", + "aria-label": translation.removeSecondaryChannel, + "on:click": remove + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "delete") + ))); + }; + function CommunicationSection() { + return /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("h2", null, translation.communication), /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "account_circle"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.yourName), /* @__PURE__ */ createElement( + "input", + { + "bind:value": senderName, + placeholder: translation.yourNamePlaceholder + } + ))), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "forum"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.primaryChannel), /* @__PURE__ */ createElement( + "input", + { + "bind:value": primaryChannel, + "on:enter": setChannel, + placeholder: translation.channelPlaceholder + } + ))), /* @__PURE__ */ createElement("div", { class: "flex-row width-input justify-end" }, /* @__PURE__ */ createElement( + "button", + { + class: "primary width-50", + "on:click": setChannel, + "toggle:disabled": cannotSetChannel + }, + translation.setInput, + /* @__PURE__ */ createElement("span", { class: "icon" }, "check") + )), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-row width-input margin-bottom" }, /* @__PURE__ */ createElement( + "input", + { + "bind:value": newSecondaryChannelName, + "on:enter": addSecondaryChannel, + placeholder: translation.newSecondaryChannelPlaceholder, + class: "width-100 flex" + } + ), /* @__PURE__ */ createElement( + "button", + { + class: "primary", + "on:click": addSecondaryChannel, + "toggle:disabled": cannotAddSecondaryChannel, + "aria-label": translation.addSecondaryChannel + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "add") + )), /* @__PURE__ */ createElement( + "div", + { + class: "flex-column gap", + "subscribe:children": [secondaryChannels, secondaryChannelConverter] + } + )); + } + + // src/Views/connectionSettingsView.tsx + function ConnectionInputView() { + return /* @__PURE__ */ createElement("div", { class: "flex-column" }, /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "cell_tower"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.serverAddress), /* @__PURE__ */ createElement( + "input", + { + "bind:value": serverAddress, + "on:enter": connect, + placeholder: translation.serverAddressPlaceholder + } + ))), /* @__PURE__ */ createElement("div", { class: "flex-row width-input justify-end" }, /* @__PURE__ */ createElement( + "button", + { + class: "primary width-50", + "on:click": connect, + "toggle:disabled": cannotConnect + }, + translation.connectToServer, + /* @__PURE__ */ createElement("span", { class: "icon" }, "arrow_forward") + ))); + } + + // src/Views/connectionStatusView.tsx + function ConnectionStatusView() { + return /* @__PURE__ */ createElement("div", { class: "tile width-input" }, /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("b", null, translation.connectionStatus), /* @__PURE__ */ createElement( + "span", + { + class: "success connected-only", + "subscribe:innerText": translation.connectedTo + } + ), /* @__PURE__ */ createElement("span", { class: "error disconnected-only" }, translation.disconnected))); + } + + // src/Views/connectionSection.tsx + function ConnectionSection() { + return /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("h2", null, translation.connection), ConnectionStatusView(), /* @__PURE__ */ createElement("hr", null), ConnectionInputView()); + } + + // src/Views/encryptionSection.tsx + var shouldShowKey = new State(false); + var inputType = createProxyState( + [shouldShowKey], + () => shouldShowKey.value ? "text" : "password" + ); + function EncryptionSection() { + return /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("h2", null, translation.encryption), /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "key"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.encryptionKey), /* @__PURE__ */ createElement( + "input", + { + "bind:value": encryptionKey, + "set:type": inputType, + placeholder: translation.encryptionKeyPlaceholder + } + ))), /* @__PURE__ */ createElement("label", { class: "inline" }, /* @__PURE__ */ createElement("input", { type: "checkbox", "bind:checked": shouldShowKey }), translation.showEncryptionKey)); + } + + // src/Tabs/settingsTab.tsx function SettingsTab() { - return /* @__PURE__ */ createElement("article", { id: "settings-tab" }, /* @__PURE__ */ createElement("header", null, getText("settings"))); + return /* @__PURE__ */ createElement("article", { id: "settings-tab", "toggle:connected": isConnected }, /* @__PURE__ */ createElement("header", null, translation.settings), /* @__PURE__ */ createElement("div", null, ConnectionSection(), CommunicationSection(), EncryptionSection(), /* @__PURE__ */ createElement("hr", { class: "mobile-only" }), /* @__PURE__ */ createElement( + "a", + { + href: "#message-tab", + class: "mobile-only width-100 flex-row justify-end control-gap" + }, + translation.messages, + /* @__PURE__ */ createElement("span", { class: "icon" }, "arrow_forward") + ))); } // src/index.tsx - document.body.prepend( - /* @__PURE__ */ createElement("menu", null, /* @__PURE__ */ createElement("a", { class: "tab-link", href: "#settings-tab", active: true }, /* @__PURE__ */ createElement("span", { class: "icon" }, "settings"), getText("settings")), /* @__PURE__ */ createElement("a", { class: "tab-link", href: "#message-tab" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "forum"), getText("messages"))) - ); document.querySelector("main").append(SettingsTab(), MessageTab()); + document.querySelector("main").classList.add("split"); })(); diff --git a/dist/styles.css b/dist/styles.css index 42df8f4d..c38562c9 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -257,6 +257,12 @@ TYPE src: url("Fonts/IBMPlexSans-Regular.ttf"); } +@font-face { + font-family: IBM Plex Sans; + src: url("Fonts/IBMPlexSans-Bold.ttf"); + font-weight: bold; +} + @font-face { font-family: IBM Plex Mono; src: url("Fonts/IBMPlexMono-Regular.ttf"); @@ -338,7 +344,7 @@ a, /* display etc */ .align-start { - align-items: start; + align-items: start !important; } button:not(.tile), @@ -349,13 +355,16 @@ label.inline, .modal, select, textarea, -.align-center, .tile:not(label) { align-items: center; } +.align-center { + align-items: center !important; +} + .align-end { - align-items: end; + align-items: end !important; } label, @@ -369,8 +378,6 @@ input[type="checkbox"], input[type="radio"], label.inline, main, -.flex-column, -.flex-row, .modal[open], .modal > div, .tile, @@ -378,8 +385,13 @@ main, display: flex; } +.flex-column, +.flex-row { + display: flex; +} + .grid { - display: grid; + display: grid !important; } button:not(.tile), @@ -395,49 +407,78 @@ textarea { } main, -.flex, .tile, .tile > div { flex: 1; } -.flex-column, +.flex { + flex: 1 !important; +} + +.flex-no { + flex: 0 !important; +} + .modal > div, .tile > div { flex-direction: column; } +.flex-column { + flex-direction: column !important; +} + +.flex-row { + flex-direction: row !important; +} + +.flex-wrap { + flex-wrap: wrap !important; +} + button, -.tile { +label.inline, +.tile, +.control-gap { gap: var(--gap-in-controls); } .gap { - gap: var(--gap-between-tiles); + gap: var(--gap-between-tiles) !important; } header, -button, -.justify-apart { +button { justify-content: space-between; } -.justify-start, +.justify-apart { + justify-content: space-between !important; +} + .tile { justify-content: start; } +.justify-start { + justify-content: start !important; +} + footer, header button, input[type="checkbox"], input[type="radio"], -.justify-center, .modal { justify-content: center; } +.justify-center { + justify-content: center !important; +} + .justify-end { - justify-content: end; + justify-content: end !important; } /* opacity */ @@ -455,12 +496,19 @@ label span, /* padding, margin, etc */ h1, h2, -h3, +h3 { + margin: var(--control-padding) 0px; +} + main, p { margin: 0px; } +.margin-0 { + margin: 0px !important; +} + hr { margin: var(--content-margin) 0px; } @@ -473,6 +521,10 @@ label:not(.tile) { margin-bottom: var(--label-margin-bottom); } +.margin-bottom { + margin-bottom: var(--gap-between-tiles) !important; +} + label.tile { margin-bottom: var(--gap-between-tiles); } @@ -491,6 +543,10 @@ input[type="radio"] { padding: 0px; } +.padding-0 { + padding: 0 !important; +} + article > div { padding: var(--content-padding); } @@ -501,11 +557,20 @@ select { padding: 0px var(--control-padding); } +.padding-h { + padding: 0px var(--control-padding) !important; +} + +label.inline, textarea, .tile:not(label) { padding: var(--control-padding); } +.padding { + padding: var(--control-padding) !important; +} + .modal main { padding: var(--modal-padding); } @@ -522,13 +587,17 @@ label.tile:has(> span.icon) { padding-left: var(--control-padding); } +label.tile:has(> span.icon) input { + padding-left: 0px; +} + article > div { padding-bottom: calc( var(--menu-height-mobile) + var(--control-height) + var(--content-padding) ); } -label { +label:not(.inline) { text-indent: var(--control-padding); } @@ -545,76 +614,104 @@ label.tile:has(> span.icon) { article > div, footer, header, -select + .icon, -.absolute { +select + .icon { position: absolute; } -.modal, -.fixed { +.absolute { + position: absolute !important; +} + +.modal { position: fixed; } +.fixed { + position: fixed !important; +} + article, main, input, -label, -.relative { +label { position: relative; } +.relative { + position: relative !important; +} + .sticky { - position: sticky; + position: sticky !important; } article > div, header, -.modal, -.top-0 { +.modal { top: 0; } -article > div, -footer, -header, -.modal, -.left-0 { - left: 0px; +.top-0 { + top: 0 !important; } article > div, footer, header, -.modal, -.right-0 { +.modal { + left: 0px; +} + +.left-0 { + left: 0px !important; +} + +article > div, +footer, +header, +.modal { right: 0px; } +.right-0 { + right: 0px !important; +} + select + .icon { right: var(--control-marker-padding); } footer { + bottom: var(--safe-area-bottom); +} + +body:has(> menu) footer { bottom: var(--menu-height-mobile); } article > div, select + .icon, -.modal, -.bottom-0 { +.modal { bottom: 0px; } +.bottom-0 { + bottom: 0px !important; +} + /* size */ article, main, select + .icon, -.height-100, .modal > div, .modal main { height: 100%; } +.height-100 { + height: 100% !important; +} + button:not(.tile):not(header *), footer, input:not([type="checkbox"]):not([type="radio"]), @@ -634,7 +731,7 @@ article { } .width-50 { - width: 50%; + width: 50% !important; } article, @@ -645,12 +742,15 @@ main, select, textarea, .modal > div, +.tile, +.tile > div { + width: 100%; +} + .width-content, .width-input, -.tile, -.tile > div, .width-100 { - width: 100%; + width: 100% !important; } header button { @@ -853,7 +953,7 @@ main { scroll-snap-type: x mandatory; } -/* desktop */ +/* tablet & up */ @media (min-width: 640px) { /* basic */ .mobile-only { @@ -876,19 +976,25 @@ main { } p, - .width-content, .modal > div { max-width: var(--max-content-width); } + .width-content { + max-width: var(--max-content-width) !important; + } + input, label, select, - textarea, - .width-input { + textarea { max-width: var(--max-input-width); } + .width-input { + max-width: var(--max-input-width) !important; + } + /* layout */ body { flex-direction: row; @@ -929,6 +1035,13 @@ main { } } +/* desktop */ +@media (min-width: 1024px) { + :root { + --split-pane-width-desktop: 400px; + } +} + /* ICONS */ diff --git a/package.json b/package.json index e45d3b13..3f355fd4 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "dependencies": { - "bloatless-react": "^1.2.0", - "esbuild": "latest", - "udn-frontend": "^1.0.1" - }, - "scripts": { - "build": "esbuild src/index.tsx --bundle --outdir=dist" - } + "dependencies": { + "bloatless-react": "^1.2.1", + "esbuild": "latest", + "udn-frontend": "^1.0.2" + }, + "scripts": { + "build": "esbuild src/index.tsx --bundle --outdir=dist" + } } diff --git a/src/Tabs/messageTab.tsx b/src/Tabs/messageTab.tsx new file mode 100644 index 00000000..f9323dd4 --- /dev/null +++ b/src/Tabs/messageTab.tsx @@ -0,0 +1,15 @@ +import * as React from "bloatless-react"; + +import { MessageComposer } from "../Views/messageComposer"; +import { ThreadView } from "../Views/threadView"; +import { translation } from "../translations"; + +export function MessageTab() { + return ( +
+
{translation.messages}
+ {ThreadView()} + +
+ ); +} diff --git a/src/Tabs/settingsTab.tsx b/src/Tabs/settingsTab.tsx new file mode 100644 index 00000000..5ea7b010 --- /dev/null +++ b/src/Tabs/settingsTab.tsx @@ -0,0 +1,28 @@ +import * as React from "bloatless-react"; + +import { CommunicationSection } from "../Views/communicationSection"; +import { ConnectionSection } from "../Views/connectionSection"; +import { EncryptionSection } from "../Views/encryptionSection"; +import { isConnected } from "../model"; +import { translation } from "../translations"; + +export function SettingsTab() { + return ( +
+
{translation.settings}
+
+ {ConnectionSection()} + {CommunicationSection()} + {EncryptionSection()} +
+ + {translation.messages} + arrow_forward + +
+
+ ); +} diff --git a/src/Views/communicationSection.tsx b/src/Views/communicationSection.tsx new file mode 100644 index 00000000..fbd11b56 --- /dev/null +++ b/src/Views/communicationSection.tsx @@ -0,0 +1,107 @@ +import * as React from "bloatless-react"; + +import { + Channel, + addSecondaryChannel, + cannotAddSecondaryChannel, + cannotSetChannel, + newSecondaryChannelName, + primaryChannel, + removeSecondaryChannel, + secondaryChannels, + senderName, + setChannel, +} from "../model"; + +import { translation } from "../translations"; + +const secondaryChannelConverter: React.ListItemConverter = ( + channel +) => { + function remove() { + removeSecondaryChannel(channel); + } + + return ( +
+
+ {channel.channelName} + + +
+
+ ); +}; + +export function CommunicationSection() { + return ( +
+

{translation.communication}

+ + + +
+ + +
+ +
+ +
+ +
+ + +
+ +
+
+ ); +} diff --git a/src/Views/connectionSection.tsx b/src/Views/connectionSection.tsx new file mode 100644 index 00000000..78beff27 --- /dev/null +++ b/src/Views/connectionSection.tsx @@ -0,0 +1,16 @@ +import * as React from "bloatless-react"; + +import { ConnectionInputView } from "./connectionSettingsView"; +import { ConnectionStatusView } from "./connectionStatusView"; +import { translation } from "../translations"; + +export function ConnectionSection() { + return ( +
+

{translation.connection}

+ {ConnectionStatusView()} +
+ {ConnectionInputView()} +
+ ); +} diff --git a/src/Views/connectionSettingsView.tsx b/src/Views/connectionSettingsView.tsx new file mode 100644 index 00000000..1a25134d --- /dev/null +++ b/src/Views/connectionSettingsView.tsx @@ -0,0 +1,37 @@ +import * as React from "bloatless-react"; + +import { + cannotConnect, + connect, + serverAddress, +} from "../model"; + +import { translation } from "../translations"; + +export function ConnectionInputView() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/Views/connectionStatusView.tsx b/src/Views/connectionStatusView.tsx new file mode 100644 index 00000000..de068c7d --- /dev/null +++ b/src/Views/connectionStatusView.tsx @@ -0,0 +1,18 @@ +import * as React from "bloatless-react"; + +import { translation } from "../translations"; + +export function ConnectionStatusView() { + return ( +
+
+ {translation.connectionStatus} + + {translation.disconnected} +
+
+ ); +} diff --git a/src/Views/encryptionSection.tsx b/src/Views/encryptionSection.tsx new file mode 100644 index 00000000..0929cb9d --- /dev/null +++ b/src/Views/encryptionSection.tsx @@ -0,0 +1,32 @@ +import * as React from "bloatless-react"; + +import { encryptionKey } from "../model"; +import { translation } from "../translations"; + +const shouldShowKey = new React.State(false); +const inputType = React.createProxyState([shouldShowKey], () => + shouldShowKey.value ? "text" : "password" +); + +export function EncryptionSection() { + return ( +
+

{translation.encryption}

+ + +
+ ); +} diff --git a/src/Views/messageComposer.tsx b/src/Views/messageComposer.tsx new file mode 100644 index 00000000..aec184b5 --- /dev/null +++ b/src/Views/messageComposer.tsx @@ -0,0 +1,26 @@ +import * as React from "bloatless-react"; + +import { cannotSendMessage, messageBody, sendMessage } from "../model"; + +import { translation } from "../translations"; + +export function MessageComposer() { + return ( +
+ + +
+ ); +} diff --git a/src/Views/threadView.tsx b/src/Views/threadView.tsx new file mode 100644 index 00000000..6bb59970 --- /dev/null +++ b/src/Views/threadView.tsx @@ -0,0 +1,28 @@ +import * as React from "bloatless-react"; + +import { + Message, + messages, +} from "../model"; + +const messageConverter: React.ListItemConverter = (message) => { + return ( +
+
+ + {message.sender}@{message.channel} + + {message.body} + + {new Date(message.isoDate).toLocaleString()} + +
+
+ ); +}; + +export function ThreadView() { + return ( +
+ ); +} diff --git a/src/index.tsx b/src/index.tsx index bf71573f..db3cd9f5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,19 +1,10 @@ +import "./styles.css"; + import * as React from "bloatless-react"; -import { MessageTab } from "./messageTab"; -import { SettingsTab } from "./settingsTab"; -import { getText } from "./translations"; +import { MessageTab } from "./Tabs/messageTab"; +import { SettingsTab } from "./Tabs/settingsTab"; +import { translation } from "./translations"; -document.body.prepend( - - - settings - {getText("settings")} - - - forum - {getText("messages")} - - -); document.querySelector("main")!.append(SettingsTab(), MessageTab()); +document.querySelector("main")!.classList.add("split"); diff --git a/src/messageTab.tsx b/src/messageTab.tsx deleted file mode 100644 index e485780b..00000000 --- a/src/messageTab.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import * as React from "bloatless-react"; - -import { getText } from "./translations"; - -export function MessageTab() { - return ( -
-
{getText("messages")}
-
- ); -} diff --git a/src/model.ts b/src/model.ts index 238b5853..0c3ce268 100644 --- a/src/model.ts +++ b/src/model.ts @@ -30,23 +30,46 @@ export class Message implements React.Identifiable { // STATE // connection -export const socketAddress = React.restoreState("socket-address", ""); +export const currentAddress = new React.State(""); +export const serverAddress = React.restoreState("socket-address", ""); export const isConnected = new React.State(false); -// sending -let currentChannel: string | undefined = undefined; -export const primaryChannel = React.restoreState("primary-channel", ""); -export const newAdditionalChannelName = new React.State(""); -export const additionalChannels = React.restoreListState( - "additional-channels" +export const cannotConnect = React.createProxyState( + [serverAddress, isConnected], + () => + serverAddress.value == "" || + (currentAddress.value == serverAddress.value && isConnected.value == true) ); + +// sending +export const currentPrimaryChannel = new React.State(""); +export const primaryChannel = React.restoreState("primary-channel", ""); +export const newSecondaryChannelName = new React.State(""); +export const secondaryChannels = + React.restoreListState("secondary-channels"); export const encryptionKey = React.restoreState("encryption-key", ""); export const senderName = React.restoreState("sender-name", ""); -export const message = React.restoreState("message", ""); +export const messageBody = React.restoreState("message", ""); + +export const cannotSetChannel = React.createProxyState( + [primaryChannel, currentPrimaryChannel, isConnected], + () => + primaryChannel.value == "" || + primaryChannel.value == currentPrimaryChannel.value || + isConnected.value == false +); + +export const cannotAddSecondaryChannel = React.createProxyState( + [newSecondaryChannelName], + () => newSecondaryChannelName.value == "" +); export const cannotSendMessage = React.createProxyState( - [primaryChannel, message], - () => primaryChannel.value == "" || message.value == "" + [currentPrimaryChannel, messageBody, senderName], + () => + currentPrimaryChannel.value == "" || + messageBody.value == "" || + senderName.value == "" ); // receiving @@ -54,19 +77,21 @@ export const messages = React.restoreListState("messages"); // METHODS export function connect() { - UDN.connect(socketAddress.value); + if (cannotConnect.value == true) return; + currentAddress.value = serverAddress.value; + UDN.connect(serverAddress.value); } -export function send() { +export function sendMessage() { if (cannotSendMessage.value == true) return; // get channels - const additionalChannelNames: string[] = [ - ...additionalChannels.value.values(), + const secondaryChannelNames: string[] = [ + ...secondaryChannels.value.values(), ].map((channel) => channel.channelName); const allChannelNames: string[] = [ primaryChannel.value, - ...additionalChannelNames, + ...secondaryChannelNames, ]; // create object @@ -74,28 +99,37 @@ export function send() { const messageObject = new Message( joinedChannelName, senderName.value, - message.value, + messageBody.value, new Date().toISOString() ); const messageString = JSON.stringify(messageObject); + // send UDN.sendMessage(joinedChannelName, messageString); + + // clear + messageBody.value = ""; } export function setChannel(): void { - if (currentChannel != undefined) { - UDN.unsubscribe(currentChannel); + if (cannotSetChannel.value == true) return; + + if (currentPrimaryChannel != undefined) { + UDN.unsubscribe(currentPrimaryChannel.value); } UDN.subscribe(primaryChannel.value); } -export function addAdditionalChannel(): void { - const channelObject = new Channel(newAdditionalChannelName.value); - additionalChannels.add(channelObject); +export function addSecondaryChannel(): void { + if (cannotAddSecondaryChannel.value == true) return; + + const channelObject = new Channel(newSecondaryChannelName.value); + secondaryChannels.add(channelObject); + newSecondaryChannelName.value = ""; } -export function removeAdditionalChannel(channel: Channel): void { - additionalChannels.remove(channel); +export function removeSecondaryChannel(channel: Channel): void { + secondaryChannels.remove(channel); } // LISTENERS @@ -104,7 +138,7 @@ UDN.onconnect = () => { }; UDN.onmessage = (data) => { - if (data.subscribed && data.messageChannel) { + if (data.subscribed != undefined && data.messageChannel != undefined) { return handleSubscriptionConfirmation(data.messageChannel, data.subscribed); } else if (data.messageBody) { handleMessage(data.messageBody); @@ -120,8 +154,9 @@ function handleSubscriptionConfirmation( channel: string, isSubscribed: boolean ): void { + console.log(channel, isSubscribed); if (isSubscribed == true) { - currentChannel = channel; + currentPrimaryChannel.value = channel; } } diff --git a/src/settingsTab.tsx b/src/settingsTab.tsx deleted file mode 100644 index 2cba13c5..00000000 --- a/src/settingsTab.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import * as React from "bloatless-react"; - -import { getText } from "./translations"; - -export function SettingsTab() { - return ( -
-
{getText("settings")}
-
- ); -} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 00000000..275a3da4 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,30 @@ +#settings-tab > div { + display: flex; + flex-direction: column; + gap: 2rem; +} + +#settings-tab > div > div { + width: var(--max-input-width); + min-width: 100%; + max-width: 100%; + flex: 1; +} + +/* CONNECTION */ + +article:not([connected]) .connected-only { + display: none; +} + +article:not([connected]) .disconnected-only { + display: flex; +} + +article[connected] .connected-only { + display: flex; +} + +article[connected] .disconnected-only { + display: none; +} diff --git a/src/translations.ts b/src/translations.ts index 8e5b7fe8..4eced328 100644 --- a/src/translations.ts +++ b/src/translations.ts @@ -1,19 +1,46 @@ +import * as React from "bloatless-react"; + +import { currentAddress, primaryChannel } from "./model"; + const englishTranslations = { + // general + setInput: "Set", + connectionStatus: "Connection status", + connectedTo: React.createProxyState( + [currentAddress], + () => `Connected to ${currentAddress}` + ), + disconnected: "Disconnected", + // settings settings: "Settings", + connection: "Connection", + communication: "Communication", + encryption: "Encryption", + serverAddress: "Server Address", + serverAddressPlaceholder: "ws://192.168.0.69:3000", + connectToServer: "Connect", + primaryChannel: "Primary channel", + channelPlaceholder: "my-channel", + addSecondaryChannel: "Add secondary channel", + removeSecondaryChannel: "Remove secondary channel", + newSecondaryChannelPlaceholder: "Add secondary channel", + encryptionKey: "Encryption key", + encryptionKeyPlaceholder: "1jg028ej40d", + showEncryptionKey: "Show encryption key", + yourName: "Your Name", + yourNamePlaceholder: "Jane Doe", // messages - messages: "Messages" + messages: "Messages", + composerPlaceholder: "Type a message...", + sendMessage: "Send" }; -const translations = { +const allTranslations = { en: englishTranslations, }; -export function getText(key: keyof typeof englishTranslations): string { - const language = navigator.language.substring(0, 2); - if (translations[language]) { - return translations[language][key]; - } - return translations.en[key]; -} +const language = navigator.language.substring(0, 2); +export const translation: typeof englishTranslations = + allTranslations[language] ?? allTranslations.en;