diff --git a/bun.lockb b/bun.lockb index 416051a8..bef7116e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/dist/index.css b/dist/index.css deleted file mode 100644 index fc07bd94..00000000 --- a/dist/index.css +++ /dev/null @@ -1,24 +0,0 @@ -/* 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.js b/dist/index.js index 25c17b26..56fcc999 100644 --- a/dist/index.js +++ b/dist/index.js @@ -99,10 +99,9 @@ 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); + if (!this.removalHandlers.has(item)) return; + this.removalHandlers.get(item)(item); + this.removalHandlers.delete(item); }); this.callSubscriptions(); } @@ -115,7 +114,7 @@ [...this.value.values()].forEach(handler); } handleRemoval(item, handler) { - this.removalHandlers.set(item.id, handler); + this.removalHandlers.set(item, handler); } // stringification toString() { @@ -180,28 +179,10 @@ break; } case "subscribe": { - if (directiveValue == "children") { - element.style.scrollBehavior = "smooth"; - try { - const [listState, toElement] = value; - listState.handleAddition((newItem) => { - const child = toElement(newItem); - listState.handleRemoval( - newItem, - () => child.remove() - ); - element.append(child); - element.scrollTop = element.scrollHeight; - }); - } catch { - throw `error: cannot process subscribe:children directive because ListItemConverter is not defined. Usage: "subscribe:children={[list, converter]}"; you can find a more detailed example in the documentation`; - } - } else { - const state = value; - state.subscribe( - (newValue) => element[directiveValue] = newValue - ); - } + const state = value; + state.subscribe( + (newValue) => element[directiveValue] = newValue + ); break; } case "bind": { @@ -233,6 +214,40 @@ ); break; } + case "children": { + switch (directiveValue) { + case "set": { + const state = value; + state.subscribe((newValue) => { + element.innerHTML = ""; + element.append(...[newValue].flat()); + }); + break; + } + case "append": + case "appendandscroll": + case "prepend": { + element.style.scrollBehavior = "smooth"; + try { + const [listState, toElement] = value; + listState.handleAddition((newItem) => { + const child = toElement(newItem); + listState.handleRemoval( + newItem, + () => child.remove() + ); + if (directiveValue == "append") { + element.append(child); + } else if (directiveValue == "prepend") { + element.prepend(child); + } + }); + } catch { + throw `error: cannot process subscribe:children directive because ListItemConverter is not defined. Usage: "subscribe:children={[list, converter]}"; you can find a more detailed example in the documentation`; + } + } + } + } default: element.setAttribute(attributename, value); } @@ -244,6 +259,28 @@ // src/cryptUtility.ts var IV_SIZE = 12; var ENCRYPTION_ALG = "AES-GCM"; + async function encryptString(plaintext, passphrase) { + if (!window.crypto.subtle) return false; + const iv = generateIV(); + const key = await importKey(passphrase, "encrypt"); + const encryptedArray = await encrypt(iv, key, plaintext); + const encryptionData = { + iv: uInt8ToArray(iv), + encryptedArray: uInt8ToArray(encryptedArray) + }; + return btoa(JSON.stringify(encryptionData)); + } + async function decryptString(cyphertext, passphrase) { + try { + const encrypionData = JSON.parse(atob(cyphertext)); + const iv = arrayToUint8(encrypionData.iv); + const encryptedArray = arrayToUint8(encrypionData.encryptedArray); + const key = await importKey(passphrase, "decrypt"); + return await decrypt(iv, key, encryptedArray); + } catch { + return cyphertext; + } + } function encode(string) { return new TextEncoder().encode(string); } @@ -292,6 +329,171 @@ return new Uint8Array(array); } + // src/utility.ts + var storageKeys = { + primaryChannel(id) { + return id + "primary-channel"; + }, + secondaryChannels(id) { + return id + "secondary-channels"; + }, + encyptionKey(id) { + return id + "encryption-key"; + }, + messages(id) { + return id + "messages"; + }, + composingMessage(id) { + return id + "composing-message"; + } + }; + + // src/Model/chatModel.ts + var Chat = class { + id; + isSubscribed = new State(false); + primaryChannel = new State(""); + secondaryChannels; + encryptionKey; + messages; + composingMessage; + newSecondaryChannelName; + primaryChannelInput; + cannotSendMessage; + cannotAddSecondaryChannel; + cannotSetChannel; + cannotUndoChannel; + // init + constructor(id = UUID()) { + this.id = id; + this.primaryChannel = restoreState( + storageKeys.primaryChannel(id), + "" + ); + this.secondaryChannels = restoreListState( + storageKeys.secondaryChannels(id) + ); + this.encryptionKey = restoreState(storageKeys.encyptionKey(id), ""); + this.messages = restoreListState(storageKeys.messages(id)); + this.composingMessage = restoreState( + storageKeys.composingMessage(id), + "" + ); + this.newSecondaryChannelName = new State(""); + this.primaryChannelInput = new State(this.primaryChannel.value); + this.cannotSendMessage = createProxyState( + [ + this.primaryChannel, + this.composingMessage, + this.isSubscribed, + isConnected, + senderName + ], + () => this.primaryChannel.value == "" || this.composingMessage.value == "" || senderName.value == "" || this.isSubscribed.value == false || isConnected.value == false + ); + this.cannotAddSecondaryChannel = createProxyState( + [this.newSecondaryChannelName], + () => this.newSecondaryChannelName.value == "" + ); + this.cannotSetChannel = createProxyState( + [this.primaryChannelInput, this.primaryChannel], + () => this.primaryChannelInput.value == "" || this.primaryChannelInput.value == this.primaryChannel.value + ); + this.cannotUndoChannel = createProxyState( + [this.primaryChannelInput, this.primaryChannel], + () => this.primaryChannelInput.value == this.primaryChannel.value + ); + } + // general + deleteSelf = () => { + Object.values(storageKeys).forEach((storageKey) => { + localStorage.removeItem(storageKey(this.id)); + }); + chats.remove(this); + chatIds.remove(this.id); + }; + // handlers + onmessage = async (data) => { + if (!data.messageChannel) return; + const channels = data.messageChannel.split("/"); + if (channels.indexOf(this.primaryChannel.value) == -1) return; + if (data.subscribed != void 0) this.handleSubscription(data.subscribed); + if (!data.messageBody) return; + const { sender, body, channel, isoDate } = JSON.parse(data.messageBody); + this.handleMessage({ + sender, + body: await decryptString(body, this.encryptionKey.value), + channel, + isoDate + }); + }; + handleSubscription = (isSubscribed) => { + this.isSubscribed.value = isSubscribed; + }; + handleMessage = (chatMessage) => { + this.messages.add(chatMessage); + }; + // messages + sendMessage = async () => { + if (this.cannotSendMessage.value == true) return; + const secondaryChannelNames = [ + ...this.secondaryChannels.value.values() + ]; + const allChannelNames = [ + this.primaryChannel.value, + ...secondaryChannelNames + ]; + const encrypted = this.encryptionKey.value == "" ? this.composingMessage.value : await encryptString( + this.composingMessage.value, + this.encryptionKey.value + ) || this.composingMessage.value; + const joinedChannelName = allChannelNames.join("/"); + const messageObject = { + channel: joinedChannelName, + sender: senderName.value, + body: encrypted, + isoDate: (/* @__PURE__ */ new Date()).toISOString() + }; + const messageString = JSON.stringify(messageObject); + UDN.sendMessage(joinedChannelName, messageString); + this.composingMessage.value = ""; + }; + clearMessages = () => { + this.messages.clear(); + }; + deleteMessage = (message) => { + this.messages.remove(message); + }; + decryptReceivedMessage = async (message) => { + message.body = await decryptString(message.body, this.encryptionKey.value); + this.messages.callSubscriptions(); + }; + // channel + setChannel = () => { + if (this.cannotSetChannel.value == true) return; + this.primaryChannel.value = this.primaryChannelInput.value; + UDN.subscribe(this.primaryChannel.value); + }; + undoChannelChange = () => { + this.primaryChannelInput.value = this.primaryChannel.value; + }; + addSecondaryChannel = () => { + if (this.cannotAddSecondaryChannel.value == true) return; + this.secondaryChannels.add(this.newSecondaryChannelName.value); + this.newSecondaryChannelName.value = ""; + }; + removeSecondaryChannel = (channel) => { + this.secondaryChannels.remove(channel); + }; + }; + function createChatWithName(name) { + const newChat = new Chat(); + chats.add(newChat); + newChat.primaryChannel.value = name; + chatIds.add(newChat.id); + UDN.subscribe(name); + } + // node_modules/udn-frontend/index.ts var UDNFrontend = class { ws; @@ -404,44 +606,158 @@ } }; + // src/Model/model.ts + var UDN = new UDNFrontend(); + var isConnected = new State(false); + var serverAddressInput = restoreState("socket-address", ""); + var didRequestConnection = restoreState( + "did-request-connection", + false + ); + var currentAddress = restoreState("current-address", ""); + var cannotDisonnect = createProxyState( + [isConnected], + () => isConnected.value == false + ); + var cannotConnect = createProxyState( + [serverAddressInput, currentAddress, isConnected], + () => serverAddressInput.value == "" || currentAddress.value == serverAddressInput.value && isConnected.value == true + ); + var cannotResetAddress = createProxyState( + [serverAddressInput, currentAddress], + () => serverAddressInput.value == currentAddress.value + ); + function connect() { + if (cannotConnect.value == true) return; + currentAddress.value = serverAddressInput.value; + isConnected.value = false; + didRequestConnection.value = true; + UDN.connect(serverAddressInput.value); + } + function disconnect() { + didRequestConnection.value = false; + if (cannotDisonnect.value == true) return; + UDN.disconnect(); + } + function resetAddressInput() { + serverAddressInput.value = currentAddress.value; + } + function subscribeChannels() { + chats.value.forEach((chat) => { + UDN.subscribe(chat.primaryChannel.value); + }); + } + UDN.onconnect = () => { + isConnected.value = true; + subscribeChannels(); + if (mailboxId.value != "") UDN.connectMailbox(mailboxId.value); + }; + UDN.onmessage = (data) => { + chats.value.forEach((chat) => { + chat.onmessage(data); + }); + }; + UDN.ondisconnect = () => { + isConnected.value = false; + }; + var mailboxId = restoreState("mailbox-id", ""); + var isMailboxActive = new State(false); + var cannotDeleteMailbox = createProxyState( + [mailboxId, isMailboxActive], + () => mailboxId.value == "" || isMailboxActive.value == false + ); + var cannotRequestMailbox = createProxyState( + [isConnected, isMailboxActive], + () => isConnected.value == false || isMailboxActive.value == true + ); + function requestMailbox() { + if (cannotRequestMailbox.value == true) return; + UDN.requestMailbox(); + } + function deleteMailbox() { + if (cannotDeleteMailbox.value == true) return; + UDN.deleteMailbox(mailboxId.value); + } + UDN.onmailboxcreate = (id) => { + mailboxId.value = id; + UDN.connectMailbox(id); + }; + UDN.onmailboxconnect = () => { + isMailboxActive.value = true; + }; + UDN.onmailboxdelete = () => { + isMailboxActive.value = false; + mailboxId.value = ""; + subscribeChannels(); + }; + var isEncryptionAvailable = window.crypto.subtle != void 0; + var senderName = restoreState("sender-name", ""); + var chats = new ListState(); + var chatIds = restoreListState("chat-ids"); + var selectedChat = new State(void 0); + var newChatName = new State(""); + var cannotCreateChat = createProxyState( + [newChatName], + () => newChatName.value == "" + ); + function createChat() { + if (cannotCreateChat.value == true) return; + createChatWithName(newChatName.value); + newChatName.value = ""; + } + function closeChatView() { + selectedChat.value = void 0; + document.getElementById("settings-tab")?.scrollIntoView(); + } + function selectChat(chat) { + selectedChat.value = chat; + document.getElementById("message-tab")?.scrollIntoView(); + } + chatIds.value.forEach((id) => chats.add(new Chat(id))); + if (serverAddressInput.value != "" && didRequestConnection.value == true) { + connect(); + } + // src/translations.ts var englishTranslations = { // general - setInput: "Set", - connectionStatus: "Connection status", - connectedTo: (server) => `Connected to "${server}"`, - disconnected: "Disconnected", - // settings - settings: "Settings", + set: "Set", + back: "Back", + undoChanges: "Undo changes", + close: "Close", + // overview + overview: "Overview", connection: "Connection", - communication: "Communication", - encryption: "Encryption", + chats: "Chats", + yourName: "Your Name", + namePlaceholder: "Jane Doe", + encryptionUnavailableTitle: "Encryption is not available", + encryptionUnavailableMessage: "Obtain this app via HTTPS or continue without encryption", serverAddress: "Server Address", serverAddressPlaceholder: "wss://192.168.0.69:3000", connectToServer: "Connect", disconnect: "Disonnect", - primaryChannel: "Primary channel", - leaveChannel: "Leave", - channelPlaceholder: "my-channel", - addSecondaryChannel: "Add secondary channel", - removeSecondaryChannel: "Remove secondary channel", - newSecondaryChannelPlaceholder: "Add secondary channel", - encryptionKey: "Encryption key", - encryptionKeyPlaceholder: "1jg028ej40d", - showEncryptionKey: "Show encryption key", - myName: "My Name", - yourNamePlaceholder: "Jane Doe", mailbox: "Mailbox", requestMailbox: "Enable", deleteMailbox: "Disable", mailboxExplanation: "When you're disconnected, messages will be kept on the server temporarily", + primaryChannel: "Primary channel", + primaryChannelPlaceholder: "my-channel", + addChat: "Add", // messages - messages: "Messages", + showChatOptions: "show chat options", + configureChatTitle: "Configure Chat", + secondaryChannel: "Secondary channel", + secondaryChannelPlaceholder: "Add secondary channel", + addSecondaryChannel: "Add secondary channel", + removeSecondaryChannel: "Remove secondary channel", + encryptionKey: "Encryption key", + encryptionKeyPlaceholder: "n10d2482dg283hg", + removeChat: "Remove chat", + clearChatMessages: "Clear chat messages", + noChatSelected: "No chat selected", composerPlaceholder: "Type a message...", sendMessage: "Send", - clearHistory: "Clear history", - encryptionUnavailableTitle: "Encryption is not available", - encryptionUnavailableMessage: "Obtain this app via HTTPS or continue without encryption", decryptMessage: "Decrypt message", copyMessage: "Copy message", deleteMessage: "Delete message" @@ -450,82 +766,86 @@ en: englishTranslations, es: { // general - setInput: "OK", - connectionStatus: "Estado de Conexi\xF3n", - connectedTo: (server) => `Conectado a "${server}"`, - disconnected: "Desconectado", - // settings - settings: "Configuraci\xF3n", - connection: "Conexi\xF3n", - communication: "Comunicaci\xF3n", - encryption: "Cifrado", - serverAddress: "Direcci\xF3n del servidor", + set: "Guardar", + back: "Atr\xE1s", + undoChanges: "Deshacer", + close: "Cerrar", + // overview + overview: "Inicio", + connection: "Conexion", + chats: "Chats", + yourName: "Tu nombre", + namePlaceholder: "Juan P\xE9rez", + encryptionUnavailableTitle: "Cifrado no disponible", + encryptionUnavailableMessage: "Obt\xE9n esta aplicaci\xF3n a traves de HTTPS o contin\xFAa sin cifrado", + serverAddress: "Direccion del servidor", serverAddressPlaceholder: "wss://192.168.0.69:3000", connectToServer: "Conectar", disconnect: "Desconectar", - primaryChannel: "Canal principal", - leaveChannel: "Salir", - channelPlaceholder: "mi-canal", - addSecondaryChannel: "A\xF1adir canal segundario", - removeSecondaryChannel: "Eliminar canal segundario", - newSecondaryChannelPlaceholder: "A\xF1adir canal segundario", - encryptionKey: "Clave de cifrada", - encryptionKeyPlaceholder: "1jg028ej40d", - showEncryptionKey: "Mostrar clave de cifrado", - myName: "Mi nombre", - yourNamePlaceholder: "Juan P\xE9rez", mailbox: "Buz\xF3n", requestMailbox: "Activar", deleteMailbox: "Desactivar", - mailboxExplanation: "Si est\xE1s sin conexi\xF3n, los mensajes se guardar\xE1n temporalmente en el servidor", + mailboxExplanation: "Cuando est\xE1s sin conexion, los mensajes se guardar\xE1n en el servidor temporalmente", + primaryChannel: "Canal principal", + primaryChannelPlaceholder: "mi-canal", + addChat: "A\xF1adir", // messages - messages: "Mensajes", + showChatOptions: "Mostrar opciones del chat", + configureChatTitle: "Configurar", + secondaryChannel: "Canal segundario", + secondaryChannelPlaceholder: "A\xF1adir canal segundario", + addSecondaryChannel: "A\xF1adir canal segundario", + removeSecondaryChannel: "Eliminar canal segundario", + encryptionKey: "Clave de cifrado", + encryptionKeyPlaceholder: "n10d2482dg283hg", + removeChat: "Eliminar chat", + clearChatMessages: "Eliminar todos mensajes", + noChatSelected: "Selecciona un chat", composerPlaceholder: "Escribe un mensaje...", sendMessage: "Enviar", - clearHistory: "Borrar historial", - encryptionUnavailableTitle: "Cifrado no disponible", - encryptionUnavailableMessage: "Obt\xE9n esta p\xE1gina a trav\xE9s de HTTPS para cifrar o contin\xFAa sin cifrado", - decryptMessage: "Descifrar mensaje", + decryptMessage: "Decifrar mensaje", copyMessage: "Copiar mensaje", - deleteMessage: "Eliminar mensaje" + deleteMessage: "Eliminar este mensaje" }, de: { // general - setInput: "OK", - connectionStatus: "Verbindungsstatus", - connectedTo: (server) => `Verbunden mit "${server}"`, - disconnected: "Verbindung getrennt", - // settings - settings: "Einstellungen", + set: "OK", + back: "Zur\xFCck", + undoChanges: "\xC4nderungen verwerfen", + close: "Schlie\xDFen", + // overview + overview: "\xDCbersicht", connection: "Verbindung", - communication: "Kommunikation", - encryption: "Verschl\xFCsselung", - serverAddress: "Serveraddresse", + chats: "Chats", + yourName: "Dein Name", + namePlaceholder: "Max Mustermann", + encryptionUnavailableTitle: "Verschl\xFCsselung nicht m\xF6glich", + encryptionUnavailableMessage: "Lade diese app \xFCber HTTPS, um Nachrichten zu verschl\xFCsseln", + serverAddress: "Serveradresse", serverAddressPlaceholder: "wss://192.168.0.69:3000", connectToServer: "Verbinden", disconnect: "Trennen", - primaryChannel: "Hauptkanal", - leaveChannel: "Verlassen", - channelPlaceholder: "mein-kanal", - addSecondaryChannel: "Zweitkanal hinzuf\xFCgen", - removeSecondaryChannel: "Zweitkanal entfernen", - newSecondaryChannelPlaceholder: "Zweitkanal hinzuf\xFCgen", - encryptionKey: "Schl\xFCssel", - encryptionKeyPlaceholder: "1jg028ej40d", - showEncryptionKey: "Schl\xFCssel anzeigen", - myName: "Mein Name", - yourNamePlaceholder: "Max Mustermann", mailbox: "Briefkasten", requestMailbox: "Aktivieren", deleteMailbox: "Deaktivieren", - mailboxExplanation: "Nachrichten werden tempor\xE4r auf dem Server gespeichert, wenn deine Verbindung getrennt ist", + mailboxExplanation: "Wenn du offline bist, werden Nachrichten auf dem Server gelagert", + primaryChannel: "Hauptkanal", + primaryChannelPlaceholder: "mein-kanal", + addChat: "Hinzuf\xFCgen", // messages - messages: "Nachrichten", + showChatOptions: "Chatoptionen einblenden", + configureChatTitle: "Chat konfigurieren", + secondaryChannel: "Zweitkanal", + secondaryChannelPlaceholder: "Zweitkanal hinzuf\xFCgen", + addSecondaryChannel: "Zweitkanal hinzuf\xFCgen", + removeSecondaryChannel: "Zweitkanal entfernen", + encryptionKey: "Schl\xFCssel", + encryptionKeyPlaceholder: "n10d2482dg283hg", + removeChat: "Chat l\xF6schen", + clearChatMessages: "Nachrichtenverlauf leeren", + noChatSelected: "Kein Chat ausgew\xE4hlt", composerPlaceholder: "Neue Nachricht...", sendMessage: "Senden", - clearHistory: "Nachrichtenverlauf leeren", - encryptionUnavailableTitle: "Verschl\xFCsselung nicht m\xF6glich", - encryptionUnavailableMessage: "Um Nachrichten zu verschl\xFCsseln, lade diese Seite \xFCber HTTPS.", decryptMessage: "Nachricht entschl\xFCsseln", copyMessage: "Nachricht kopieren", deleteMessage: "Nachricht l\xF6schen" @@ -534,390 +854,263 @@ var language = navigator.language.substring(0, 2); var translation = allTranslations[language] ?? allTranslations.en; - // src/model.ts - var UDN = new UDNFrontend(); - var Channel = class { - constructor(channelName) { - this.channelName = channelName; + // src/Views/chatOptionModal.tsx + function ChatOptionModal(chat, isPresented) { + function closeModal() { + isPresented.value = false; } - id = UUID(); - }; - var Message = class { - constructor(channel, sender, body, isoDate) { - this.channel = channel; - this.sender = sender; - this.body = body; - this.isoDate = isoDate; + function deleteChat() { + chat.deleteSelf(); + closeModal(); } - id = UUID(); - }; - var currentAddress = new State(""); - var serverAddress = restoreState("socket-address", ""); - var isConnected = new State(false); - var connectionMessage = createProxyState( - [serverAddress], - () => translation.connectedTo(serverAddress.value) - ); - var cannotDisonnect = createProxyState( - [isConnected], - () => isConnected.value == false - ); - var cannotConnect = createProxyState( - [serverAddress, currentAddress, isConnected], - () => serverAddress.value == "" || currentAddress.value == serverAddress.value && isConnected.value == true - ); - var mailboxId = restoreState("mailbox-id", ""); - var isMailboxConnected = new State(false); - var cannotRequestMailbox = createProxyState( - [isConnected, isMailboxConnected], - () => isConnected.value == false || isMailboxConnected.value == true - ); - var cannotDeleteMailbox = createProxyState( - [isConnected, mailboxId, isMailboxConnected], - () => isConnected.value == false || mailboxId.value == "" || isMailboxConnected.value == false - ); - var encryptionKey = restoreState("encryption-key", ""); - var isEncryptionUnavailable = window.crypto.subtle == void 0; - var currentPrimaryChannel = new State(""); - var primaryChannel = restoreState("primary-channel", ""); - var newSecondaryChannelName = new State(""); - var secondaryChannels = restoreListState("secondary-channels"); - var cannotSetChannel = createProxyState( - [primaryChannel, currentPrimaryChannel, isConnected], - () => primaryChannel.value == "" || isConnected.value == false - ); - var cannotLeaveChannel = createProxyState( - [currentPrimaryChannel, isConnected], - () => currentPrimaryChannel.value == "" || isConnected.value == false - ); - var cannotAddSecondaryChannel = createProxyState( - [newSecondaryChannelName], - () => newSecondaryChannelName.value == "" - ); - var senderName = restoreState("sender-name", ""); - var messageBody = restoreState("message", ""); - var cannotSendMessage = createProxyState( - [currentPrimaryChannel, messageBody, senderName, isConnected], - () => isConnected.value == false || currentPrimaryChannel.value == "" || messageBody.value == "" || senderName.value == "" - ); - var messages = restoreListState("messages"); - function connect() { - if (cannotConnect.value == true) return; - currentAddress.value = serverAddress.value; - isConnected.value = false; - UDN.connect(serverAddress.value); - } - function disconnect() { - if (cannotDisonnect.value == true) return; - UDN.disconnect(); - } - function requestMailbox() { - UDN.requestMailbox(); - } - function deleteMailbox() { - UDN.deleteMailbox(mailboxId.value); - } - function updateMailbox() { - if (!isMailboxConnected || mailboxId.value == "") return; - deleteMailbox(); - requestMailbox(); - } - async function sendMessage() { - if (cannotSendMessage.value == true) return; - const secondaryChannelNames = [ - ...secondaryChannels.value.values() - ].map((channel) => channel.channelName); - const allChannelNames = [ - primaryChannel.value, - ...secondaryChannelNames - ]; - const encrypted = await encryptMessage(); - if (encrypted == void 0) return; - const joinedChannelName = allChannelNames.join("/"); - const messageObject = new Message( - joinedChannelName, - senderName.value, - encrypted, - (/* @__PURE__ */ new Date()).toISOString() - ); - const messageString = JSON.stringify(messageObject); - UDN.sendMessage(joinedChannelName, messageString); - messageBody.value = ""; - } - function clearMessageHistory() { - messages.clear(); - } - function deleteMessage(message) { - messages.remove(message); - } - function setChannel(shouldUpdateMailbox = true) { - if (cannotSetChannel.value == true) return; - if (currentPrimaryChannel != void 0) { - UDN.unsubscribe(currentPrimaryChannel.value); - } - UDN.subscribe(primaryChannel.value); - if (shouldUpdateMailbox) { - updateMailbox(); - } - } - function leaveChannel() { - if (cannotLeaveChannel.value == true) return; - UDN.unsubscribe(currentPrimaryChannel.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); - } - async function decryptReceivedMessage(message) { - message.body = await decryptMessage(message.body); - messages.callSubscriptions(); - } - UDN.onconnect = () => { - isConnected.value = true; - setChannel(false); - if (mailboxId.value != "") { - UDN.connectMailbox(mailboxId.value); - } - }; - UDN.onmailboxcreate = (id) => { - mailboxId.value = id; - UDN.connectMailbox(id); - }; - UDN.onmailboxconnect = () => { - console.log("connected"); - isMailboxConnected.value = true; - }; - UDN.onmailboxdelete = () => { - isMailboxConnected.value = false; - mailboxId.value = ""; - setChannel(); - }; - 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; - isMailboxConnected.value = false; - }; - function handleSubscriptionConfirmation(channel, isSubscribed) { - if (isSubscribed == true) { - currentPrimaryChannel.value = channel; - } else { - currentPrimaryChannel.value = ""; - } - } - async 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, - await decryptMessage(object.body), - object.isoDate - ); - messages.add(messageObject); - } catch { - } - } - async function encryptMessage() { - if (encryptionKey.value == "") return messageBody.value; - if (!window.crypto.subtle) return messageBody.value; - const iv = generateIV(); - const key = await importKey(encryptionKey.value, "encrypt"); - const encryptedArray = await encrypt(iv, key, messageBody.value); - const encryptionData = { - iv: uInt8ToArray(iv), - encryptedArray: uInt8ToArray(encryptedArray) + const secondaryChannelConverter = (secondaryChannel) => { + function remove() { + chat.removeSecondaryChannel(secondaryChannel); + } + return /* @__PURE__ */ createElement("div", { class: "tile width-input padding-0" }, /* @__PURE__ */ createElement("div", { class: "flex-row justify-apart align-center" }, /* @__PURE__ */ createElement("b", { class: "padding-h" }, secondaryChannel), /* @__PURE__ */ createElement( + "button", + { + class: "danger", + "aria-label": translation.removeSecondaryChannel, + "on:click": remove + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "delete") + ))); }; - return btoa(JSON.stringify(encryptionData)); - } - async function decryptMessage(encryptionData) { - try { - const encrypionData = JSON.parse(atob(encryptionData)); - const iv = arrayToUint8(encrypionData.iv); - const encryptedArray = arrayToUint8(encrypionData.encryptedArray); - const key = await importKey(encryptionKey.value, "decrypt"); - return await decrypt(iv, key, encryptedArray); - } catch (error) { - console.error(error); - return encryptionData; - } - } - if (serverAddress.value != "") { - connect(); - } - - // src/Views/messageComposer.tsx - function MessageComposer() { - return /* @__PURE__ */ createElement("div", { class: "flex-row width-100" }, /* @__PURE__ */ createElement( + return /* @__PURE__ */ createElement("div", { class: "modal", "toggle:open": isPresented }, /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("main", null, /* @__PURE__ */ createElement("h2", null, translation.configureChatTitle), /* @__PURE__ */ createElement("div", 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", { - class: "width-100 flex-1", - style: "max-width: unset", - placeholder: translation.composerPlaceholder, - "bind:value": messageBody, - "on:enter": sendMessage + "bind:value": chat.primaryChannelInput, + placeholder: translation.primaryChannelPlaceholder, + "on:enter": chat.setChannel } + ))), /* @__PURE__ */ createElement("div", { class: "flex-row width-input" }, /* @__PURE__ */ createElement( + "button", + { + "aria-label": translation.undoChanges, + class: "flex justify-center", + "on:click": chat.undoChannelChange, + "toggle:disabled": chat.cannotUndoChannel + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "undo") ), /* @__PURE__ */ createElement( "button", { - class: "primary", - "on:click": sendMessage, - "toggle:disabled": cannotSendMessage + "aria-label": translation.set, + class: "flex justify-center primary", + "on:click": chat.setChannel, + "toggle:disabled": chat.cannotSetChannel }, - /* @__PURE__ */ createElement("span", { class: "icon" }, "send") - )); - } - - // src/Views/threadView.tsx - var messageConverter = (message) => { - const messageBody2 = createProxyState([messages], () => message.body); - function copyMessage() { - navigator.clipboard.writeText(message.body); - } - function decrypt2() { - decryptReceivedMessage(message); - } - function remove() { - deleteMessage(message); - } - return /* @__PURE__ */ createElement("div", { class: "tile width-100 flex-no padding-0" }, /* @__PURE__ */ createElement("div", { class: "flex-column" }, /* @__PURE__ */ createElement("div", { class: "flex-row justify-apart align-center secondary" }, /* @__PURE__ */ createElement("span", { class: "padding-h ellipsis" }, /* @__PURE__ */ createElement("b", { class: "info" }, message.sender), " - ", message.channel), /* @__PURE__ */ createElement("span", { class: "flex-row" }, /* @__PURE__ */ createElement("button", { "aria-label": translation.copyMessage, "on:click": copyMessage }, /* @__PURE__ */ createElement("span", { class: "icon" }, "content_copy")), /* @__PURE__ */ createElement("button", { "aria-label": translation.decryptMessage, "on:click": decrypt2 }, /* @__PURE__ */ createElement("span", { class: "icon" }, "key")), /* @__PURE__ */ createElement("button", { "aria-label": translation.deleteMessage, "on:click": remove }, /* @__PURE__ */ createElement("span", { class: "icon" }, "delete")))), /* @__PURE__ */ createElement("div", { class: "flex-column padding-h padding-bottom" }, /* @__PURE__ */ createElement("b", { class: "break-word", "subscribe:innerText": messageBody2 }), /* @__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, translation.messages, /* @__PURE__ */ createElement("span", null, /* @__PURE__ */ createElement( - "button", - { - "aria-label": translation.clearHistory, - "on:click": clearMessageHistory - }, - /* @__PURE__ */ createElement("span", { class: "icon" }, "delete_sweep") - ))), ThreadView(), /* @__PURE__ */ createElement("footer", null, MessageComposer())); - } - - // 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.myName), /* @__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: "danger width-50", - "on:click": leaveChannel, - "toggle:disabled": cannotLeaveChannel - }, - translation.leaveChannel - ), /* @__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( + ))), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-row margin-bottom width-input" }, /* @__PURE__ */ createElement( "input", { - "bind:value": newSecondaryChannelName, - "on:enter": addSecondaryChannel, - placeholder: translation.newSecondaryChannelPlaceholder, - class: "width-100 flex" + "aria-label": translation.secondaryChannel, + placeholder: translation.secondaryChannelPlaceholder, + "bind:value": chat.newSecondaryChannelName, + "on:enter": chat.addSecondaryChannel } ), /* @__PURE__ */ createElement( "button", { class: "primary", - "on:click": addSecondaryChannel, - "toggle:disabled": cannotAddSecondaryChannel, - "aria-label": translation.addSecondaryChannel + "toggle:disabled": chat.cannotAddSecondaryChannel, + "aria-label": translation.addSecondaryChannel, + "on:click": chat.addSecondaryChannel }, /* @__PURE__ */ createElement("span", { class: "icon" }, "add") )), /* @__PURE__ */ createElement( "div", { class: "flex-column gap", - "subscribe:children": [secondaryChannels, secondaryChannelConverter] + "children:prepend": [ + chat.secondaryChannels, + secondaryChannelConverter + ] + } + ), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "key"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.encryptionKey), /* @__PURE__ */ createElement( + "input", + { + placeholder: translation.encryptionKeyPlaceholder, + "bind:value": chat.encryptionKey + } + )))), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, /* @__PURE__ */ createElement("button", { class: "danger", "on:click": chat.clearMessages }, translation.clearChatMessages, /* @__PURE__ */ createElement("span", { class: "icon" }, "delete_sweep")), /* @__PURE__ */ createElement("button", { class: "danger", "on:click": deleteChat }, translation.removeChat, /* @__PURE__ */ createElement("span", { class: "icon" }, "delete")))), /* @__PURE__ */ createElement("button", { "on:click": closeModal }, translation.close, /* @__PURE__ */ createElement("span", { class: "icon" }, "close")))); + } + + // src/Views/messageComposer.tsx + function MessageComposer(chat) { + 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": chat.composingMessage, + "on:enter": chat.sendMessage + } + ), /* @__PURE__ */ createElement( + "button", + { + class: "primary", + "on:click": chat.sendMessage, + "toggle:disabled": chat.cannotSendMessage + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "send") + )); + } + + // src/Views/threadView.tsx + function ThreadView(chat) { + const messageConverter = (message) => { + function copyMessage() { + navigator.clipboard.writeText(message.body); + } + function decrypt2() { + chat.decryptReceivedMessage(message); + } + function remove() { + chat.deleteMessage(message); + } + const messageBody = createProxyState( + [chat.messages], + () => message.body + ); + return /* @__PURE__ */ createElement("div", { class: "tile width-100 flex-no padding-0" }, /* @__PURE__ */ createElement("div", { class: "flex-column" }, /* @__PURE__ */ createElement("div", { class: "flex-row justify-apart align-center secondary" }, /* @__PURE__ */ createElement("span", { class: "padding-h ellipsis" }, /* @__PURE__ */ createElement("b", { class: "info" }, message.sender), " - ", message.channel), /* @__PURE__ */ createElement("span", { class: "flex-row" }, /* @__PURE__ */ createElement( + "button", + { + "aria-label": translation.copyMessage, + "on:click": copyMessage + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "content_copy") + ), /* @__PURE__ */ createElement( + "button", + { + "aria-label": translation.decryptMessage, + "on:click": decrypt2 + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "key") + ), /* @__PURE__ */ createElement("button", { "aria-label": translation.deleteMessage, "on:click": remove }, /* @__PURE__ */ createElement("span", { class: "icon" }, "delete")))), /* @__PURE__ */ createElement("div", { class: "flex-column padding-h padding-bottom" }, /* @__PURE__ */ createElement("b", { class: "break-word", "subscribe:innerText": messageBody }), /* @__PURE__ */ createElement("span", { class: "secondary" }, new Date(message.isoDate).toLocaleString())))); + }; + const listElement = /* @__PURE__ */ createElement( + "div", + { + class: "flex-column gap", + "children:append": [chat.messages, messageConverter] + } + ); + chat.messages.handleAddition(() => { + const scrollFromBottom = listElement.scrollHeight - (listElement.scrollTop + listElement.offsetHeight); + if (scrollFromBottom > 400) return; + listElement.scrollTop = listElement.scrollHeight; + }); + setTimeout(() => listElement.scrollTop = listElement.scrollHeight, 50); + return listElement; + } + + // src/Tabs/messageTab.tsx + function MessageTab() { + const messageTabContent = createProxyState([selectedChat], () => { + if (selectedChat.value == void 0) + return /* @__PURE__ */ createElement("div", { class: "width-100 height-100 flex-column align-center justify-center" }, /* @__PURE__ */ createElement("span", { class: "secondary" }, translation.noChatSelected)); + const chat = selectedChat.value; + const isShowingOptions = new State(false); + function showOptions() { + isShowingOptions.value = true; + } + return [ + /* @__PURE__ */ createElement("header", { class: "padding-0" }, /* @__PURE__ */ createElement("span", { class: "flex-row align-center" }, /* @__PURE__ */ createElement("button", { "aria-label": translation.back, "on:click": closeChatView }, /* @__PURE__ */ createElement("span", { class: "icon" }, "arrow_back")), /* @__PURE__ */ createElement("span", { "subscribe:innerText": chat.primaryChannel })), /* @__PURE__ */ createElement("span", null, /* @__PURE__ */ createElement( + "button", + { + "aria-label": translation.showChatOptions, + "on:click": showOptions + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "tune") + ))), + ThreadView(chat), + /* @__PURE__ */ createElement("footer", null, MessageComposer(chat)), + ChatOptionModal(chat, isShowingOptions) + ]; + }); + return /* @__PURE__ */ createElement("article", { id: "message-tab", "children:set": messageTabContent }); + } + + // src/Views/chatListSection.tsx + var chatConverter = (chat) => { + function select() { + selectChat(chat); + } + const isSelected = createProxyState( + [selectedChat], + () => selectedChat.value == chat + ); + return /* @__PURE__ */ createElement("button", { class: "tile", "on:click": select, "toggle:selected": isSelected }, /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("b", { class: "ellipsis", "subscribe:innerText": chat.primaryChannel })), /* @__PURE__ */ createElement("span", { class: "icon" }, "arrow_forward")); + }; + function ChatListSection() { + return /* @__PURE__ */ createElement("div", { class: "flex-column" }, /* @__PURE__ */ createElement("h2", null, translation.chats), /* @__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": newChatName, + placeholder: translation.primaryChannelPlaceholder, + "on:enter": createChat + } + ))), /* @__PURE__ */ createElement("div", { class: "flex-row justify-end" }, /* @__PURE__ */ createElement( + "button", + { + class: "primary width-50", + "toggle:disabled": cannotCreateChat, + "on:click": createChat + }, + translation.addChat, + /* @__PURE__ */ createElement("span", { class: "icon" }, "add") + )), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement( + "div", + { + class: "flex-column gap", + "children:append": [chats, chatConverter] } )); } - // 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( + // src/Views/connectionSection.tsx + function ConnectionSection() { + return /* @__PURE__ */ createElement("div", { class: "flex-column" }, /* @__PURE__ */ createElement("h2", null, translation.connection), /* @__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, + "bind:value": serverAddressInput, "on:enter": connect, placeholder: translation.serverAddressPlaceholder } ))), /* @__PURE__ */ createElement("div", { class: "flex-row width-input justify-end" }, /* @__PURE__ */ createElement( "button", { - class: "danger width-50", + class: "danger width-100 flex-1 justify-center", + "aria-label": translation.disconnect, "on:click": disconnect, "toggle:disabled": cannotDisonnect }, - translation.disconnect + /* @__PURE__ */ createElement("span", { class: "icon" }, "close") ), /* @__PURE__ */ createElement( "button", { - class: "primary width-50", + class: "width-100 flex-1 justify-center", + "aria-label": translation.undoChanges, + "on:click": resetAddressInput, + "toggle:disabled": cannotResetAddress + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "undo") + ), /* @__PURE__ */ createElement( + "button", + { + class: "primary width-100 flex-1 justify-center", + "aria-label": translation.connectToServer, "on:click": connect, "toggle:disabled": cannotConnect }, - translation.connectToServer, /* @__PURE__ */ createElement("span", { class: "icon" }, "arrow_forward") )), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "inbox"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("b", null, translation.mailbox), /* @__PURE__ */ createElement("span", { class: "secondary" }, translation.mailboxExplanation))), /* @__PURE__ */ createElement("div", { class: "flex-row width-input justify-end" }, /* @__PURE__ */ createElement( "button", { - class: "danger width-50", + class: "danger width-100 flex-1", "on:click": deleteMailbox, "toggle:disabled": cannotDeleteMailbox }, @@ -925,7 +1118,7 @@ ), /* @__PURE__ */ createElement( "button", { - class: "primary width-50", + class: "primary width-100 flex-1", "on:click": requestMailbox, "toggle:disabled": cannotRequestMailbox }, @@ -934,57 +1127,23 @@ ))); } - // 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": connectionMessage - } - ), /* @__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( - "div", - { - class: "error tile margin-bottom", - "toggle:hidden": !isEncryptionUnavailable - }, - /* @__PURE__ */ createElement("span", { class: "icon" }, "warning"), - /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("b", null, translation.encryptionUnavailableTitle), /* @__PURE__ */ createElement("p", null, translation.encryptionUnavailableMessage)) - ), /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "key"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.encryptionKey), /* @__PURE__ */ createElement( + // src/Views/personalSection.tsx + function PersonalSection() { + return /* @__PURE__ */ createElement("div", { class: "flex-column" }, /* @__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", { - "toggle:disabled": isEncryptionUnavailable, - "bind:value": encryptionKey, - "set:type": inputType, - placeholder: translation.encryptionKeyPlaceholder + "bind:value": senderName, + placeholder: translation.namePlaceholder } - ))), /* @__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", "toggle:connected": isConnected }, /* @__PURE__ */ createElement("header", null, translation.settings), /* @__PURE__ */ createElement("div", null, ConnectionSection(), CommunicationSection(), EncryptionSection())); + // src/Tabs/overviewTab.tsx + function OverviewTab() { + return /* @__PURE__ */ createElement("article", { id: "settings-tab", "toggle:connected": isConnected }, /* @__PURE__ */ createElement("header", null, translation.overview), /* @__PURE__ */ createElement("div", { class: "flex-column large-gap" }, /* @__PURE__ */ createElement("div", { class: "tile error flex-no", "toggle:hidden": isEncryptionAvailable }, /* @__PURE__ */ createElement("span", { class: "icon" }, "warning"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("b", null, translation.encryptionUnavailableTitle), /* @__PURE__ */ createElement("span", { class: "secondary" }, translation.encryptionUnavailableMessage))), PersonalSection(), ConnectionSection(), ChatListSection())); } // src/index.tsx - document.body.prepend( - /* @__PURE__ */ createElement("menu", { class: "mobile-only" }, /* @__PURE__ */ createElement("a", { class: "tab-link", href: "#settings-tab", active: true }, /* @__PURE__ */ createElement("span", { class: "icon" }, "settings"), translation.settings), /* @__PURE__ */ createElement("a", { class: "tab-link", href: "#message-tab" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "forum"), translation.messages)) - ); - document.querySelector("main").append(SettingsTab(), MessageTab()); + document.querySelector("main").append(OverviewTab(), MessageTab()); document.querySelector("main").classList.add("split"); })(); diff --git a/dist/styles.css b/dist/styles.css index ec3eafe2..dfe09f5c 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -214,6 +214,7 @@ SIZES --gap-between-tiles: 4px; --gap-in-tab-links: 1rem; --gap-in-controls: 1.2rem; + --large-gap: 2rem; /* margin */ --label-top-padding: 0.25rem; @@ -245,7 +246,7 @@ SIZES --safe-area-top: env(safe-area-inset-top); --safe-area-left: env(safe-area-inset-left); --safe-area-right: env(safe-area-inset-right); - --safe-area-bottom: max(env(safe-area-inset-bottom), var(--control-padding)); + --safe-area-bottom: env(safe-area-inset-bottom); } /* @@ -421,6 +422,7 @@ main, } .modal > div, +.modal main, .tile > div { flex-direction: column; } @@ -448,6 +450,10 @@ label.inline, gap: var(--gap-between-tiles) !important; } +.large-gap { + gap: var(--large-gap) !important; +} + header, button { justify-content: space-between; diff --git a/package.json b/package.json index 96bd64f7..996bd862 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "bloatless-react": "^1.2.5", + "bloatless-react": "^1.3.1-beta1", "esbuild": "latest", "udn-frontend": "^1.0.7-beta1" }, diff --git a/src/Model/chatModel.ts b/src/Model/chatModel.ts new file mode 100644 index 00000000..df950993 --- /dev/null +++ b/src/Model/chatModel.ts @@ -0,0 +1,215 @@ +import * as React from "bloatless-react"; + +import { UDN, chatIds, chats, isConnected, senderName } from "../Model/model"; +import { decryptString, encryptString } from "../cryptUtility"; + +import { Message } from "udn-frontend"; +import { storageKeys } from "../utility"; + +// TYPES +// message +export interface ChatMessage { + channel: string; + sender: string; + body: string; + isoDate: string; +} + +// chat +export class Chat { + id: string; + + isSubscribed = new React.State(false); + primaryChannel = new React.State(""); + secondaryChannels: React.ListState; + + encryptionKey: React.State; + messages: React.ListState; + + composingMessage: React.State; + newSecondaryChannelName: React.State; + primaryChannelInput: React.State; + + cannotSendMessage: React.State; + cannotAddSecondaryChannel: React.State; + cannotSetChannel: React.State; + cannotUndoChannel: React.State; + + // init + constructor(id: string = React.UUID()) { + this.id = id; + + // channels + this.primaryChannel = React.restoreState( + storageKeys.primaryChannel(id), + "" + ); + this.secondaryChannels = React.restoreListState( + storageKeys.secondaryChannels(id) + ); + + // messaging + this.encryptionKey = React.restoreState(storageKeys.encyptionKey(id), ""); + this.messages = React.restoreListState(storageKeys.messages(id)); + + // inputs + this.composingMessage = React.restoreState( + storageKeys.composingMessage(id), + "" + ); + this.newSecondaryChannelName = new React.State(""); + this.primaryChannelInput = new React.State(this.primaryChannel.value); + + // guards + this.cannotSendMessage = React.createProxyState( + [ + this.primaryChannel, + this.composingMessage, + this.isSubscribed, + isConnected, + senderName, + ], + () => + this.primaryChannel.value == "" || + this.composingMessage.value == "" || + senderName.value == "" || + this.isSubscribed.value == false || + isConnected.value == false + ); + this.cannotAddSecondaryChannel = React.createProxyState( + [this.newSecondaryChannelName], + () => this.newSecondaryChannelName.value == "" + ); + this.cannotSetChannel = React.createProxyState( + [this.primaryChannelInput, this.primaryChannel], + () => + this.primaryChannelInput.value == "" || + this.primaryChannelInput.value == this.primaryChannel.value + ); + this.cannotUndoChannel = React.createProxyState( + [this.primaryChannelInput, this.primaryChannel], + () => this.primaryChannelInput.value == this.primaryChannel.value + ); + } + + // general + deleteSelf = () => { + Object.values(storageKeys).forEach((storageKey) => { + localStorage.removeItem(storageKey(this.id)); + }); + + chats.remove(this); + chatIds.remove(this.id); + }; + + // handlers + onmessage = async (data: Message): Promise => { + if (!data.messageChannel) return; + const channels = data.messageChannel.split("/"); + if (channels.indexOf(this.primaryChannel.value) == -1) return; + + if (data.subscribed != undefined) this.handleSubscription(data.subscribed); + + if (!data.messageBody) return; + const { sender, body, channel, isoDate } = JSON.parse(data.messageBody); + this.handleMessage({ + sender, + body: await decryptString(body, this.encryptionKey.value), + channel, + isoDate, + }); + }; + + handleSubscription = (isSubscribed: boolean): void => { + this.isSubscribed.value = isSubscribed; + }; + + handleMessage = (chatMessage: ChatMessage): void => { + this.messages.add(chatMessage); + }; + + // messages + sendMessage = async (): Promise => { + if (this.cannotSendMessage.value == true) return; + + // get channels + const secondaryChannelNames: string[] = [ + ...this.secondaryChannels.value.values(), + ]; + const allChannelNames: string[] = [ + this.primaryChannel.value, + ...secondaryChannelNames, + ]; + + // encrypt + const encrypted = + this.encryptionKey.value == "" + ? this.composingMessage.value + : (await encryptString( + this.composingMessage.value, + this.encryptionKey.value + )) || this.composingMessage.value; + + // create object + const joinedChannelName = allChannelNames.join("/"); + const messageObject: ChatMessage = { + channel: joinedChannelName, + sender: senderName.value, + body: encrypted, + isoDate: new Date().toISOString(), + }; + const messageString = JSON.stringify(messageObject); + + // send + UDN.sendMessage(joinedChannelName, messageString); + + // clear + this.composingMessage.value = ""; + }; + + clearMessages = (): void => { + this.messages.clear(); + }; + + deleteMessage = (message: ChatMessage): void => { + this.messages.remove(message); + }; + + decryptReceivedMessage = async (message: ChatMessage): Promise => { + message.body = await decryptString(message.body, this.encryptionKey.value); + this.messages.callSubscriptions(); + }; + + // channel + setChannel = (): void => { + if (this.cannotSetChannel.value == true) return; + this.primaryChannel.value = this.primaryChannelInput.value; + UDN.subscribe(this.primaryChannel.value); + }; + + undoChannelChange = (): void => { + this.primaryChannelInput.value = this.primaryChannel.value; + }; + + addSecondaryChannel = (): void => { + if (this.cannotAddSecondaryChannel.value == true) return; + + this.secondaryChannels.add(this.newSecondaryChannelName.value); + this.newSecondaryChannelName.value = ""; + }; + + removeSecondaryChannel = (channel: string): void => { + this.secondaryChannels.remove(channel); + }; +} + +// METHODS +export function createChatWithName(name: string): void { + const newChat = new Chat(); + chats.add(newChat); + + newChat.primaryChannel.value = name; + chatIds.add(newChat.id); + + UDN.subscribe(name); +} diff --git a/src/Model/model.ts b/src/Model/model.ts new file mode 100644 index 00000000..56cba749 --- /dev/null +++ b/src/Model/model.ts @@ -0,0 +1,163 @@ +import * as React from "bloatless-react"; + +import { Chat, createChatWithName } from "./chatModel"; + +import UDNFrontend from "udn-frontend"; + +export const UDN = new UDNFrontend(); + +// CONNECTION +export const isConnected = new React.State(false); + +export const serverAddressInput = React.restoreState("socket-address", ""); +export const didRequestConnection = React.restoreState( + "did-request-connection", + false +); +export const currentAddress = React.restoreState("current-address", ""); + +export const cannotDisonnect = React.createProxyState( + [isConnected], + () => isConnected.value == false +); + +export const cannotConnect = React.createProxyState( + [serverAddressInput, currentAddress, isConnected], + () => + serverAddressInput.value == "" || + (currentAddress.value == serverAddressInput.value && + isConnected.value == true) +); + +export const cannotResetAddress = React.createProxyState( + [serverAddressInput, currentAddress], + () => serverAddressInput.value == currentAddress.value +); + +// methods +export function connect(): void { + if (cannotConnect.value == true) return; + + currentAddress.value = serverAddressInput.value; + + isConnected.value = false; + didRequestConnection.value = true; + + UDN.connect(serverAddressInput.value); +} + +export function disconnect(): void { + didRequestConnection.value = false; + if (cannotDisonnect.value == true) return; + UDN.disconnect(); +} + +export function resetAddressInput(): void { + serverAddressInput.value = currentAddress.value; +} + +export function subscribeChannels() { + chats.value.forEach((chat) => { + UDN.subscribe(chat.primaryChannel.value); + }); +} + +// listeners +UDN.onconnect = () => { + isConnected.value = true; + subscribeChannels(); + if (mailboxId.value != "") UDN.connectMailbox(mailboxId.value); +}; + +UDN.onmessage = (data) => { + chats.value.forEach((chat) => { + chat.onmessage(data); + }); +}; + +UDN.ondisconnect = () => { + isConnected.value = false; +}; + +// MAILBOX +export const mailboxId = React.restoreState("mailbox-id", ""); +export const isMailboxActive = new React.State(false); + +export const cannotDeleteMailbox = React.createProxyState( + [mailboxId, isMailboxActive], + () => mailboxId.value == "" || isMailboxActive.value == false +); +export const cannotRequestMailbox = React.createProxyState( + [isConnected, isMailboxActive], + () => isConnected.value == false || isMailboxActive.value == true +); + +export function requestMailbox(): void { + if (cannotRequestMailbox.value == true) return; + UDN.requestMailbox(); +} + +export function deleteMailbox(): void { + if (cannotDeleteMailbox.value == true) return; + UDN.deleteMailbox(mailboxId.value); +} + +export function updateMailbox(): void { + deleteMailbox(); + updateMailbox(); +} + +UDN.onmailboxcreate = (id) => { + mailboxId.value = id; + UDN.connectMailbox(id); +}; + +UDN.onmailboxconnect = () => { + isMailboxActive.value = true; +}; + +UDN.onmailboxdelete = () => { + isMailboxActive.value = false; + mailboxId.value = ""; + subscribeChannels(); +}; + +// MISC +export const isEncryptionAvailable = window.crypto.subtle != undefined; +export const senderName = React.restoreState("sender-name", ""); + +// CHAT +export const chats = new React.ListState(); +export const chatIds = React.restoreListState("chat-ids"); + +export const selectedChat = new React.State(undefined); +export const newChatName = new React.State(""); + +export const cannotCreateChat = React.createProxyState( + [newChatName], + () => newChatName.value == "" +); + +export function createChat() { + if (cannotCreateChat.value == true) return; + + createChatWithName(newChatName.value); + newChatName.value = ""; +} + +export function closeChatView() { + selectedChat.value = undefined; + document.getElementById("settings-tab")?.scrollIntoView(); +} + +export function selectChat(chat: Chat) { + selectedChat.value = chat; + document.getElementById("message-tab")?.scrollIntoView(); +} + +chatIds.value.forEach((id) => chats.add(new Chat(id))); + +// INIT +if (serverAddressInput.value != "" && didRequestConnection.value == true) { + connect(); +} diff --git a/src/Tabs/messageTab.tsx b/src/Tabs/messageTab.tsx index d0960fa3..3c531d08 100644 --- a/src/Tabs/messageTab.tsx +++ b/src/Tabs/messageTab.tsx @@ -1,26 +1,54 @@ import * as React from "bloatless-react"; +import { closeChatView, selectedChat } from "../Model/model"; + +import { ChatOptionModal } from "../Views/chatOptionModal"; import { MessageComposer } from "../Views/messageComposer"; import { ThreadView } from "../Views/threadView"; -import { clearMessageHistory } from "../model"; import { translation } from "../translations"; export function MessageTab() { - return ( -
-
- {translation.messages} + const messageTabContent = React.createProxyState([selectedChat], () => { + if (selectedChat.value == undefined) + return ( +
+ {translation.noChatSelected} +
+ ); + + const chat = selectedChat.value; + const isShowingOptions = new React.State(false); + + function showOptions() { + isShowingOptions.value = true; + } + + return [ +
+ + + + + -
- {ThreadView()} -
{MessageComposer()}
-
- ); + , + + ThreadView(chat), + +
{MessageComposer(chat)}
, + + ChatOptionModal(chat, isShowingOptions), + ]; + }); + + return
; } diff --git a/src/Tabs/overviewTab.tsx b/src/Tabs/overviewTab.tsx new file mode 100644 index 00000000..e94b48ee --- /dev/null +++ b/src/Tabs/overviewTab.tsx @@ -0,0 +1,32 @@ +import * as React from "bloatless-react"; + +import { isConnected, isEncryptionAvailable } from "../Model/model"; + +import { ChatListSection } from "../Views/chatListSection"; +import { ConnectionSection } from "../Views/connectionSection"; +import { PersonalSection } from "../Views/personalSection"; +import { translation } from "../translations"; + +export function OverviewTab() { + return ( +
+
{translation.overview}
+ +
+
+ warning +
+ {translation.encryptionUnavailableTitle} + + {translation.encryptionUnavailableMessage} + +
+
+ + {PersonalSection()} + {ConnectionSection()} + {ChatListSection()} +
+
+ ); +} diff --git a/src/Tabs/settingsTab.tsx b/src/Tabs/settingsTab.tsx deleted file mode 100644 index d83809c4..00000000 --- a/src/Tabs/settingsTab.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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()} -
-
- ); -} diff --git a/src/Views/chatListSection.tsx b/src/Views/chatListSection.tsx new file mode 100644 index 00000000..0e0e30f4 --- /dev/null +++ b/src/Views/chatListSection.tsx @@ -0,0 +1,70 @@ +import * as React from "bloatless-react"; + +import { + cannotCreateChat, + chats, + createChat, + newChatName, + selectChat, + selectedChat, +} from "../Model/model"; + +import { Chat } from "../Model/chatModel"; +import { translation } from "../translations"; + +const chatConverter: React.ListItemConverter = (chat) => { + function select() { + selectChat(chat); + } + + const isSelected = React.createProxyState( + [selectedChat], + () => selectedChat.value == chat + ); + + return ( + + ); +}; + +export function ChatListSection() { + return ( +
+

{translation.chats}

+ + +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/Views/chatOptionModal.tsx b/src/Views/chatOptionModal.tsx new file mode 100644 index 00000000..b7e9a44a --- /dev/null +++ b/src/Views/chatOptionModal.tsx @@ -0,0 +1,140 @@ +import * as React from "bloatless-react"; + +import { Chat } from "../Model/chatModel"; +import { translation } from "../translations"; + +export function ChatOptionModal(chat: Chat, isPresented: React.State) { + function closeModal() { + isPresented.value = false; + } + + function deleteChat() { + chat.deleteSelf(); + closeModal(); + } + + const secondaryChannelConverter: React.ListItemConverter = ( + secondaryChannel + ) => { + function remove() { + chat.removeSecondaryChannel(secondaryChannel); + } + + return ( +
+
+ {secondaryChannel} + +
+
+ ); + }; + + return ( + + ); +} diff --git a/src/Views/communicationSection.tsx b/src/Views/communicationSection.tsx deleted file mode 100644 index fad3639a..00000000 --- a/src/Views/communicationSection.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import * as React from "bloatless-react"; - -import { - Channel, - addSecondaryChannel, - cannotAddSecondaryChannel, - cannotLeaveChannel, - cannotSetChannel, - leaveChannel, - 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 index 78beff27..f45b5d0f 100644 --- a/src/Views/connectionSection.tsx +++ b/src/Views/connectionSection.tsx @@ -1,16 +1,90 @@ import * as React from "bloatless-react"; -import { ConnectionInputView } from "./connectionSettingsView"; -import { ConnectionStatusView } from "./connectionStatusView"; +import { + cannotConnect, + cannotDeleteMailbox, + cannotDisonnect, + cannotRequestMailbox, + cannotResetAddress, + connect, + deleteMailbox, + disconnect, + requestMailbox, + resetAddressInput, + serverAddressInput, +} from "../Model/model"; + import { translation } from "../translations"; export function ConnectionSection() { return ( -
-

{translation.connection}

- {ConnectionStatusView()} -
- {ConnectionInputView()} +
+

{translation.connection}

+ + +
+ + + +
+ +
+ +
+ inbox +
+ {translation.mailbox} + {translation.mailboxExplanation} +
+
+
+ + +
); } diff --git a/src/Views/connectionSettingsView.tsx b/src/Views/connectionSettingsView.tsx deleted file mode 100644 index 502b757d..00000000 --- a/src/Views/connectionSettingsView.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import * as React from "bloatless-react"; - -import { - cannotConnect, - cannotDeleteMailbox, - cannotDisonnect, - cannotRequestMailbox, - connect, - deleteMailbox, - disconnect, - requestMailbox, - serverAddress, -} from "../model"; - -import { translation } from "../translations"; - -export function ConnectionInputView() { - return ( -
- -
- - -
- -
- -
- inbox -
- {translation.mailbox} - {translation.mailboxExplanation} -
-
-
- - -
-
- ); -} diff --git a/src/Views/connectionStatusView.tsx b/src/Views/connectionStatusView.tsx deleted file mode 100644 index 9a5ec25a..00000000 --- a/src/Views/connectionStatusView.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "bloatless-react"; - -import { connectionMessage } from "../model"; -import { translation } from "../translations"; - -export function ConnectionStatusView() { - return ( -
-
- {translation.connectionStatus} - - {translation.disconnected} -
-
- ); -} diff --git a/src/Views/encryptionSection.tsx b/src/Views/encryptionSection.tsx deleted file mode 100644 index 8533bd9c..00000000 --- a/src/Views/encryptionSection.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import * as React from "bloatless-react"; - -import { encryptionKey, isEncryptionUnavailable } 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}

-
- warning -
- {translation.encryptionUnavailableTitle} -

{translation.encryptionUnavailableMessage}

-
-
- - - -
- ); -} diff --git a/src/Views/messageComposer.tsx b/src/Views/messageComposer.tsx index aec184b5..8a6a4fe9 100644 --- a/src/Views/messageComposer.tsx +++ b/src/Views/messageComposer.tsx @@ -1,23 +1,23 @@ import * as React from "bloatless-react"; -import { cannotSendMessage, messageBody, sendMessage } from "../model"; - +import { Chat } from "../Model/chatModel"; import { translation } from "../translations"; -export function MessageComposer() { +export function MessageComposer(chat: Chat) { return (
+ {" "} diff --git a/src/Views/personalSection.tsx b/src/Views/personalSection.tsx new file mode 100644 index 00000000..8fe8db75 --- /dev/null +++ b/src/Views/personalSection.tsx @@ -0,0 +1,21 @@ +import * as React from "bloatless-react"; + +import { senderName } from "../Model/model"; +import { translation } from "../translations"; + +export function PersonalSection() { + return ( +
+ +
+ ); +} diff --git a/src/Views/threadView.tsx b/src/Views/threadView.tsx index d236ac27..81395623 100644 --- a/src/Views/threadView.tsx +++ b/src/Views/threadView.tsx @@ -1,64 +1,78 @@ import * as React from "bloatless-react"; -import { - Message, - decryptReceivedMessage, - deleteMessage, - messages, -} from "../model"; +import { Chat, ChatMessage } from "../Model/chatModel"; import { translation } from "../translations"; -const messageConverter: React.ListItemConverter = (message) => { - const messageBody = React.createProxyState([messages], () => message.body); +export function ThreadView(chat: Chat) { + const messageConverter: React.ListItemConverter = (message) => { + function copyMessage() { + navigator.clipboard.writeText(message.body); + } - function copyMessage() { - navigator.clipboard.writeText(message.body); - } + function decrypt() { + chat.decryptReceivedMessage(message); + } - function decrypt() { - decryptReceivedMessage(message); - } + function remove() { + chat.deleteMessage(message); + } - function remove() { - deleteMessage(message); - } + const messageBody = React.createProxyState( + [chat.messages], + () => message.body + ); - return ( -
-
-
- - {message.sender} - {message.channel} - - - - - - -
-
- - - {new Date(message.isoDate).toLocaleString()} - + return ( +
+
+
+ + {message.sender} - {message.channel} + + + + + + +
+
+ + + {new Date(message.isoDate).toLocaleString()} + +
-
- ); -}; + ); + }; -export function ThreadView() { - return ( + const listElement = (
); + chat.messages.handleAddition(() => { + const scrollFromBottom = + listElement.scrollHeight - + (listElement.scrollTop + listElement.offsetHeight); + if (scrollFromBottom > 400) return; + + listElement.scrollTop = listElement.scrollHeight; + }); + setTimeout(() => (listElement.scrollTop = listElement.scrollHeight), 50); + return listElement; } diff --git a/src/cryptUtility.ts b/src/cryptUtility.ts index 58a904c6..a0ae34f4 100644 --- a/src/cryptUtility.ts +++ b/src/cryptUtility.ts @@ -1,6 +1,40 @@ const IV_SIZE = 12; const ENCRYPTION_ALG = "AES-GCM"; +// minimal +export async function encryptString( + plaintext: string, + passphrase: string +): Promise { + if (!window.crypto.subtle) return false; + + const iv = generateIV(); + const key = await importKey(passphrase, "encrypt"); + const encryptedArray = await encrypt(iv, key, plaintext); + + const encryptionData = { + iv: uInt8ToArray(iv), + encryptedArray: uInt8ToArray(encryptedArray), + }; + return btoa(JSON.stringify(encryptionData)); +} + +export async function decryptString( + cyphertext: string, + passphrase: string +): Promise { + try { + const encrypionData = JSON.parse(atob(cyphertext)); + const iv = arrayToUint8(encrypionData.iv); + const encryptedArray = arrayToUint8(encrypionData.encryptedArray); + + const key = await importKey(passphrase, "decrypt"); + return await decrypt(iv, key, encryptedArray); + } catch { + return cyphertext; + } +} + // process export function encode(string: string): Uint8Array { return new TextEncoder().encode(string); diff --git a/src/index.tsx b/src/index.tsx index f46ab735..aa7178fb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,22 +1,5 @@ -import "./styles.css"; - -import * as React from "bloatless-react"; - import { MessageTab } from "./Tabs/messageTab"; -import { SettingsTab } from "./Tabs/settingsTab"; -import { translation } from "./translations"; +import { OverviewTab } from "./Tabs/overviewTab"; -document.body.prepend( - - - settings - {translation.settings} - - - forum - {translation.messages} - - -); -document.querySelector("main")!.append(SettingsTab(), MessageTab()); +document.querySelector("main")!.append(OverviewTab(), MessageTab()); document.querySelector("main")!.classList.add("split"); diff --git a/src/model.ts b/src/model.ts deleted file mode 100644 index 54a2f243..00000000 --- a/src/model.ts +++ /dev/null @@ -1,335 +0,0 @@ -import * as React from "bloatless-react"; - -import { - arrayToUint8, - decode, - decrypt, - encrypt, - generateIV, - importKey, - splitMergedString, - uInt8ToArray, -} from "./cryptUtility"; - -import UDNFrontend from "udn-frontend"; -import { translation } from "./translations"; - -const UDN = new UDNFrontend(); - -// TYPE -export class Channel implements React.Identifiable { - id = React.UUID(); - constructor(public channelName: string) {} -} - -export interface Message extends React.Identifiable { - channel: string; - sender: string; - body: string; - isoDate: string; -} - -export class Message implements React.Identifiable { - id = React.UUID(); - - constructor( - public channel: string, - public sender: string, - public body: string, - public isoDate: string - ) {} -} - -// STATE -// connection -export const currentAddress = new React.State(""); -export const serverAddress = React.restoreState("socket-address", ""); -export const isConnected = new React.State(false); -export const connectionMessage = React.createProxyState([serverAddress], () => - translation.connectedTo(serverAddress.value) -); - -export const cannotDisonnect = React.createProxyState( - [isConnected], - () => isConnected.value == false -); - -export const cannotConnect = React.createProxyState( - [serverAddress, currentAddress, isConnected], - () => - serverAddress.value == "" || - (currentAddress.value == serverAddress.value && isConnected.value == true) -); - -// mailbox -export const mailboxId = React.restoreState("mailbox-id", ""); -export const isMailboxConnected = new React.State(false); - -export const cannotRequestMailbox = React.createProxyState( - [isConnected, isMailboxConnected], - () => isConnected.value == false || isMailboxConnected.value == true -); - -export const cannotDeleteMailbox = React.createProxyState( - [isConnected, mailboxId, isMailboxConnected], - () => - isConnected.value == false || - mailboxId.value == "" || - isMailboxConnected.value == false -); - -// encryption -export const encryptionKey = React.restoreState("encryption-key", ""); -export const isEncryptionUnavailable = window.crypto.subtle == undefined; - -// channel -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 cannotSetChannel = React.createProxyState( - [primaryChannel, currentPrimaryChannel, isConnected], - () => primaryChannel.value == "" || isConnected.value == false -); - -export const cannotLeaveChannel = React.createProxyState( - [currentPrimaryChannel, isConnected], - () => currentPrimaryChannel.value == "" || isConnected.value == false -); - -export const cannotAddSecondaryChannel = React.createProxyState( - [newSecondaryChannelName], - () => newSecondaryChannelName.value == "" -); - -// sending -export const senderName = React.restoreState("sender-name", ""); - -export const messageBody = React.restoreState("message", ""); - -export const cannotSendMessage = React.createProxyState( - [currentPrimaryChannel, messageBody, senderName, isConnected], - () => - isConnected.value == false || - currentPrimaryChannel.value == "" || - messageBody.value == "" || - senderName.value == "" -); - -// receiving -export const messages = React.restoreListState("messages"); - -// METHODS -// connection -export function connect(): void { - if (cannotConnect.value == true) return; - currentAddress.value = serverAddress.value; - isConnected.value = false; - UDN.connect(serverAddress.value); -} - -export function disconnect(): void { - if (cannotDisonnect.value == true) return; - UDN.disconnect(); -} - -// mailbox -export function requestMailbox(): void { - UDN.requestMailbox(); -} - -export function deleteMailbox(): void { - UDN.deleteMailbox(mailboxId.value); -} - -export function updateMailbox(): void { - if (!isMailboxConnected || mailboxId.value == "") return; - - deleteMailbox(); - requestMailbox(); -} - -// messages -export async function sendMessage(): Promise { - if (cannotSendMessage.value == true) return; - - // get channels - const secondaryChannelNames: string[] = [ - ...secondaryChannels.value.values(), - ].map((channel) => channel.channelName); - const allChannelNames: string[] = [ - primaryChannel.value, - ...secondaryChannelNames, - ]; - - // encrypt - const encrypted = await encryptMessage(); - if (encrypted == undefined) return; - - // create object - const joinedChannelName = allChannelNames.join("/"); - const messageObject = new Message( - joinedChannelName, - senderName.value, - encrypted, - new Date().toISOString() - ); - const messageString = JSON.stringify(messageObject); - - // send - UDN.sendMessage(joinedChannelName, messageString); - - // clear - messageBody.value = ""; -} - -export function clearMessageHistory(): void { - messages.clear(); -} - -export function deleteMessage(message: Message): void { - messages.remove(message); -} - -// channels -export function setChannel(shouldUpdateMailbox: boolean = true): void { - if (cannotSetChannel.value == true) return; - - if (currentPrimaryChannel != undefined) { - UDN.unsubscribe(currentPrimaryChannel.value); - } - UDN.subscribe(primaryChannel.value); - - if (shouldUpdateMailbox) { - updateMailbox(); - } -} - -export function leaveChannel(): void { - if (cannotLeaveChannel.value == true) return; - - UDN.unsubscribe(currentPrimaryChannel.value); -} - -export function addSecondaryChannel(): void { - if (cannotAddSecondaryChannel.value == true) return; - - const channelObject = new Channel(newSecondaryChannelName.value); - secondaryChannels.add(channelObject); - newSecondaryChannelName.value = ""; -} - -export function removeSecondaryChannel(channel: Channel): void { - secondaryChannels.remove(channel); -} - -// encryption -export async function decryptReceivedMessage(message: Message): Promise { - message.body = await decryptMessage(message.body); - messages.callSubscriptions(); -} - -// LISTENERS -UDN.onconnect = () => { - isConnected.value = true; - setChannel(false); - - if (mailboxId.value != "") { - UDN.connectMailbox(mailboxId.value); - } -}; - -UDN.onmailboxcreate = (id) => { - mailboxId.value = id; - UDN.connectMailbox(id); -}; - -UDN.onmailboxconnect = () => { - console.log("connected") - isMailboxConnected.value = true; -}; - -UDN.onmailboxdelete = () => { - isMailboxConnected.value = false; - mailboxId.value = ""; - setChannel(); -}; - -UDN.onmessage = (data) => { - if (data.subscribed != undefined && data.messageChannel != undefined) { - return handleSubscriptionConfirmation(data.messageChannel, data.subscribed); - } else if (data.messageBody) { - handleMessage(data.messageBody); - } -}; - -UDN.ondisconnect = () => { - isConnected.value = false; - isMailboxConnected.value = false; -}; - -// UTILITY -function handleSubscriptionConfirmation( - channel: string, - isSubscribed: boolean -): void { - if (isSubscribed == true) { - currentPrimaryChannel.value = channel; - } else { - currentPrimaryChannel.value = ""; - } -} - -async function handleMessage(body: string): Promise { - try { - const object: Message = JSON.parse(body); - if (!object.channel || !object.sender || !object.body || !object.isoDate) - return; - - const messageObject = new Message( - object.channel, - object.sender, - await decryptMessage(object.body), - object.isoDate - ); - messages.add(messageObject); - } catch {} -} - -async function encryptMessage(): Promise { - if (encryptionKey.value == "") return messageBody.value; - - if (!window.crypto.subtle) return messageBody.value; - - const iv = generateIV(); - const key = await importKey(encryptionKey.value, "encrypt"); - const encryptedArray = await encrypt(iv, key, messageBody.value); - - const encryptionData = { - iv: uInt8ToArray(iv), - encryptedArray: uInt8ToArray(encryptedArray), - }; - return btoa(JSON.stringify(encryptionData)); -} - -async function decryptMessage(encryptionData: string): Promise { - try { - const encrypionData = JSON.parse(atob(encryptionData)); - const iv = arrayToUint8(encrypionData.iv); - const encryptedArray = arrayToUint8(encrypionData.encryptedArray); - - const key = await importKey(encryptionKey.value, "decrypt"); - return await decrypt(iv, key, encryptedArray); - } catch (error) { - console.error(error); - return encryptionData; - } -} - -// INIT -if (serverAddress.value != "") { - connect(); -} diff --git a/src/styles.css b/src/styles.css deleted file mode 100644 index 275a3da4..00000000 --- a/src/styles.css +++ /dev/null @@ -1,30 +0,0 @@ -#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 ae9f4a98..6436ffca 100644 --- a/src/translations.ts +++ b/src/translations.ts @@ -1,44 +1,58 @@ const englishTranslations = { // general - setInput: "Set", - connectionStatus: "Connection status", - connectedTo: (server: string) => `Connected to "${server}"`, - disconnected: "Disconnected", + set: "Set", + back: "Back", + undoChanges: "Undo changes", + close: "Close", + + // overview + overview: "Overview", - // settings - settings: "Settings", connection: "Connection", - communication: "Communication", - encryption: "Encryption", + chats: "Chats", + + yourName: "Your Name", + namePlaceholder: "Jane Doe", + + encryptionUnavailableTitle: "Encryption is not available", + encryptionUnavailableMessage: + "Obtain this app via HTTPS or continue without encryption", + serverAddress: "Server Address", serverAddressPlaceholder: "wss://192.168.0.69:3000", + connectToServer: "Connect", disconnect: "Disonnect", - primaryChannel: "Primary channel", - leaveChannel: "Leave", - channelPlaceholder: "my-channel", - addSecondaryChannel: "Add secondary channel", - removeSecondaryChannel: "Remove secondary channel", - newSecondaryChannelPlaceholder: "Add secondary channel", - encryptionKey: "Encryption key", - encryptionKeyPlaceholder: "1jg028ej40d", - showEncryptionKey: "Show encryption key", - myName: "My Name", - yourNamePlaceholder: "Jane Doe", + mailbox: "Mailbox", requestMailbox: "Enable", deleteMailbox: "Disable", mailboxExplanation: "When you're disconnected, messages will be kept on the server temporarily", + primaryChannel: "Primary channel", + primaryChannelPlaceholder: "my-channel", + addChat: "Add", + // messages - messages: "Messages", + showChatOptions: "show chat options", + configureChatTitle: "Configure Chat", + + secondaryChannel: "Secondary channel", + secondaryChannelPlaceholder: "Add secondary channel", + addSecondaryChannel: "Add secondary channel", + removeSecondaryChannel: "Remove secondary channel", + + encryptionKey: "Encryption key", + encryptionKeyPlaceholder: "n10d2482dg283hg", + + removeChat: "Remove chat", + clearChatMessages: "Clear chat messages", + + noChatSelected: "No chat selected", + composerPlaceholder: "Type a message...", sendMessage: "Send", - clearHistory: "Clear history", - encryptionUnavailableTitle: "Encryption is not available", - encryptionUnavailableMessage: - "Obtain this app via HTTPS or continue without encryption", decryptMessage: "Decrypt message", copyMessage: "Copy message", @@ -49,91 +63,119 @@ const allTranslations: { [key: string]: typeof englishTranslations } = { en: englishTranslations, es: { // general - setInput: "OK", - connectionStatus: "Estado de Conexión", - connectedTo: (server: string) => `Conectado a "${server}"`, - disconnected: "Desconectado", + set: "Guardar", + back: "Atrás", + undoChanges: "Deshacer", + close: "Cerrar", - // settings - settings: "Configuración", - connection: "Conexión", - communication: "Comunicación", - encryption: "Cifrado", - serverAddress: "Dirección del servidor", + // overview + overview: "Resumen", + + connection: "Conexion", + chats: "Chats", + + yourName: "Tu nombre", + namePlaceholder: "Juan Pérez", + + encryptionUnavailableTitle: "Cifrado no disponible", + encryptionUnavailableMessage: + "Obtén esta aplicación a traves de HTTPS o continúa sin cifrado", + + serverAddress: "Direccion del servidor", serverAddressPlaceholder: "wss://192.168.0.69:3000", + connectToServer: "Conectar", disconnect: "Desconectar", - primaryChannel: "Canal principal", - leaveChannel: "Salir", - channelPlaceholder: "mi-canal", - addSecondaryChannel: "Añadir canal segundario", - removeSecondaryChannel: "Eliminar canal segundario", - newSecondaryChannelPlaceholder: "Añadir canal segundario", - encryptionKey: "Clave de cifrada", - encryptionKeyPlaceholder: "1jg028ej40d", - showEncryptionKey: "Mostrar clave de cifrado", - myName: "Mi nombre", - yourNamePlaceholder: "Juan Pérez", + mailbox: "Buzón", requestMailbox: "Activar", deleteMailbox: "Desactivar", mailboxExplanation: "Si estás sin conexión, los mensajes se guardarán temporalmente en el servidor", + primaryChannel: "Canal principal", + primaryChannelPlaceholder: "mi-canal", + addChat: "Añadir", + // messages - messages: "Mensajes", + showChatOptions: "Mostrar opciones del chat", + configureChatTitle: "Configurar chat", + + secondaryChannel: "Canal segundario", + secondaryChannelPlaceholder: "Añadir canal segundario", + addSecondaryChannel: "Añadir canal segundario", + removeSecondaryChannel: "Eliminar canal segundario", + + encryptionKey: "Clave de cifrado", + encryptionKeyPlaceholder: "n10d2482dg283hg", + + removeChat: "Eliminar chat", + clearChatMessages: "Eliminar todos mensajes", + + noChatSelected: "Selecciona un chat", + composerPlaceholder: "Escribe un mensaje...", sendMessage: "Enviar", - clearHistory: "Borrar historial", - encryptionUnavailableTitle: "Cifrado no disponible", - encryptionUnavailableMessage: - "Obtén esta página a través de HTTPS para cifrar o continúa sin cifrado", decryptMessage: "Descifrar mensaje", copyMessage: "Copiar mensaje", - deleteMessage: "Eliminar mensaje", + deleteMessage: "Eliminar este mensaje", }, de: { // general - setInput: "OK", - connectionStatus: "Verbindungsstatus", - connectedTo: (server: string) => `Verbunden mit "${server}"`, - disconnected: "Verbindung getrennt", + set: "OK", + back: "Zurück", + undoChanges: "Änderungen verwerfen", + close: "Schließen", + + // overview + overview: "Übersicht", - // settings - settings: "Einstellungen", connection: "Verbindung", - communication: "Kommunikation", - encryption: "Verschlüsselung", - serverAddress: "Serveraddresse", + chats: "Chats", + + yourName: "Dein Name", + namePlaceholder: "Max Mustermann", + + encryptionUnavailableTitle: "Verschlüsselung nicht möglich", + encryptionUnavailableMessage: + "Lade diese app über HTTPS, um Nachrichten zu verschlüsseln", + + serverAddress: "Serveradresse", serverAddressPlaceholder: "wss://192.168.0.69:3000", + connectToServer: "Verbinden", disconnect: "Trennen", - primaryChannel: "Hauptkanal", - leaveChannel: "Verlassen", - channelPlaceholder: "mein-kanal", - addSecondaryChannel: "Zweitkanal hinzufügen", - removeSecondaryChannel: "Zweitkanal entfernen", - newSecondaryChannelPlaceholder: "Zweitkanal hinzufügen", - encryptionKey: "Schlüssel", - encryptionKeyPlaceholder: "1jg028ej40d", - showEncryptionKey: "Schlüssel anzeigen", - myName: "Mein Name", - yourNamePlaceholder: "Max Mustermann", + mailbox: "Briefkasten", requestMailbox: "Aktivieren", deleteMailbox: "Deaktivieren", mailboxExplanation: - "Nachrichten werden temporär auf dem Server gespeichert, wenn deine Verbindung getrennt ist", + "Wenn du offline bist, werden Nachrichten auf dem Server gelagert", + + primaryChannel: "Hauptkanal", + primaryChannelPlaceholder: "mein-kanal", + addChat: "Hinzufügen", // messages - messages: "Nachrichten", + showChatOptions: "Chatoptionen einblenden", + configureChatTitle: "Chat konfigurieren", + + secondaryChannel: "Zweitkanal", + secondaryChannelPlaceholder: "Zweitkanal hinzufügen", + addSecondaryChannel: "Zweitkanal hinzufügen", + removeSecondaryChannel: "Zweitkanal entfernen", + + encryptionKey: "Schlüssel", + encryptionKeyPlaceholder: "n10d2482dg283hg", + + removeChat: "Chat löschen", + clearChatMessages: "Nachrichtenverlauf leeren", + + noChatSelected: "Kein Chat ausgewählt", + composerPlaceholder: "Neue Nachricht...", sendMessage: "Senden", - clearHistory: "Nachrichtenverlauf leeren", - encryptionUnavailableTitle: "Verschlüsselung nicht möglich", - encryptionUnavailableMessage: - "Um Nachrichten zu verschlüsseln, lade diese Seite über HTTPS.", decryptMessage: "Nachricht entschlüsseln", copyMessage: "Nachricht kopieren", diff --git a/src/utility.ts b/src/utility.ts new file mode 100644 index 00000000..95152fd0 --- /dev/null +++ b/src/utility.ts @@ -0,0 +1,21 @@ +export const storageKeys = { + primaryChannel(id: string): string { + return id + "primary-channel"; + }, + + secondaryChannels(id: string): string { + return id + "secondary-channels"; + }, + + encyptionKey(id: string): string { + return id + "encryption-key"; + }, + + messages(id: string): string { + return id + "messages"; + }, + + composingMessage(id: string): string { + return id + "composing-message"; + }, +};