diff --git a/bun.lockb b/bun.lockb index 3db90f3c..50c6d575 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/dist/index.css b/dist/index.css index bd34d2a3..33593a85 100644 --- a/dist/index.css +++ b/dist/index.css @@ -2,3 +2,66 @@ [unread] { border-left: 10px solid var(--text-warn); } +:root { + --thread-view-height-with-tools: 8rem; + --thread-view-width-with-tools: 20rem; + --footer-top-line: calc(var(--control-height) + var(--safe-area-bottom)); +} +#message-tab { + flex-direction: column; +} +.message-tab-content-wrapper { + display: flex; + flex-direction: column; +} +.chat-object-view { + display: none; +} +[showingchattools] :is(.thread-view, .chat-object-view) { + overflow: scroll; + position: absolute; + padding: 0px; +} +[showingchattools] header { + border-bottom: 1px solid var(--border); +} +[showingchattools] .chat-object-view { + display: flex; + top: var(--header-height); + bottom: calc(var(--thread-view-height-with-tools) + var(--footer-top-line)); + border-bottom: 1px solid var(--border); +} +[showingchattools] .thread-view { + display: flex; + height: var(--thread-view-height-with-tools); + top: unset; + bottom: var(--footer-top-line); + flex: 1; +} +@media (min-width: 640px) and (max-width: 1400px) { + main.split:has([showingchattools]) { + display: flex; + } + main.split:has([showingchattools]) #settings-tab { + display: none; + } +} +@media (min-width: 640px) { + [showingchattools] :is(.thread-view, .chat-object-view) { + top: var(--header-height); + bottom: var(--footer-top-line); + } + [showingchattools] .chat-object-view { + left: 0px; + right: var(--thread-view-width-with-tools); + border-bottom: none; + border-right: 1px solid var(--border); + } + [showingchattools] .thread-view { + display: flex; + left: unset; + right: 0px; + height: unset; + width: var(--thread-view-width-with-tools); + } +} diff --git a/dist/index.js b/dist/index.js index 0e7671f7..e07c670f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -76,6 +76,9 @@ this._bindings.add(fn); fn(this._value); } + subscribeSilent(fn) { + this._bindings.add(fn); + } // stringify toString() { return JSON.stringify(this._value); @@ -118,7 +121,48 @@ } // stringification toString() { - const array = [...this.value]; + const array = [...this.value.values()]; + const json = JSON.stringify(array); + return json; + } + }; + var MapState = class extends State { + additionHandlers = /* @__PURE__ */ new Set(); + removalHandlers = /* @__PURE__ */ new Map(); + // init + constructor(initialItems) { + super(new Map(initialItems)); + } + // list + set(key, item) { + this.remove(key); + this.value.set(key, item); + this.additionHandlers.forEach((handler) => handler(item)); + this.callSubscriptions(); + } + remove(key) { + const item = this.value.get(key); + if (!item) return; + this.value.delete(key); + this.callSubscriptions(); + if (!this.removalHandlers.has(item)) return; + this.removalHandlers.get(item)(item); + this.removalHandlers.delete(item); + } + clear() { + [...this.value.keys()].forEach((key) => this.remove(key)); + } + // handlers + handleAddition(handler) { + this.additionHandlers.add(handler); + [...this.value.values()].forEach(handler); + } + handleRemoval(item, handler) { + this.removalHandlers.set(item, handler); + } + // stringification + toString() { + const array = [...this.value.entries()]; const json = JSON.stringify(array); return json; } @@ -130,6 +174,9 @@ ); return proxyState; } + function bulkSubscribe(statesToSubscibe, fn) { + statesToSubscibe.forEach((state) => state.subscribeSilent(fn)); + } function persistState(localStorageKey, state) { state.subscribe(() => { const stringifiedValue = state.toString(); @@ -143,9 +190,8 @@ persistState(localStorageKey, state); return state; } - function restoreListState(localStorageKey) { + function restoreListState(localStorageKey, initialItems = []) { const storedString = localStorage.getItem(localStorageKey) ?? ""; - let initialItems = []; try { const array = JSON.parse(storedString); if (!Array.isArray(array)) throw ""; @@ -156,6 +202,18 @@ persistState(localStorageKey, state); return state; } + function restoreMapState(localStorageKey, initialItems = []) { + const storedString = localStorage.getItem(localStorageKey) ?? ""; + try { + const array = JSON.parse(storedString); + if (!Array.isArray(array)) throw ""; + initialItems = array; + } catch { + } + const state = new MapState(initialItems); + persistState(localStorageKey, state); + return state; + } function createElement(tagName, attributes = {}, ...children) { const element = document.createElement(tagName); if (attributes != null) @@ -243,7 +301,7 @@ } }); } 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`; + throw `error: cannot process subscribe:children directive because StateItemConverter is not defined. Usage: "subscribe:children={[list, converter]}"; you can find a more detailed example in the documentation`; } } } @@ -346,9 +404,15 @@ messages(id) { return id + "messages"; }, + objects(id) { + return id + "items"; + }, outbox(id) { return id + "outbox"; }, + itemOutbox(id) { + return id + "item-outbox"; + }, composingMessage(id) { return id + "composing-message"; } @@ -363,7 +427,9 @@ secondaryChannels; encryptionKey; messages; + objects; outbox; + objectOutbox; isOutBoxEmpty; composingMessage; primaryChannelInput; @@ -374,6 +440,7 @@ cannotSetChannel; cannotUndoChannel; cannotClearMessages; + cannotClearObjects; // init constructor(id = UUID()) { this.id = id; @@ -390,7 +457,9 @@ ); this.encryptionKey = restoreState(storageKeys.encyptionKey(id), ""); this.messages = restoreListState(storageKeys.messages(id)); + this.objects = restoreMapState(storageKeys.objects(id)); this.outbox = restoreListState(storageKeys.outbox(id)); + this.objectOutbox = restoreMapState(storageKeys.itemOutbox(id)); this.isOutBoxEmpty = createProxyState( [this.outbox], () => this.outbox.value.size == 0 @@ -425,6 +494,10 @@ [this.messages], () => this.messages.value.size == 0 ); + this.cannotClearObjects = createProxyState( + [this.objects], + () => this.objects.value.size == 0 + ); } // general deleteSelf = () => { @@ -441,7 +514,17 @@ 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); + const message = JSON.parse(data.messageBody); + const { sender, body, channel, isoDate, messageObjectString } = message; + if (messageObjectString) { + const decryptedMessageObjectString = await decryptString( + messageObjectString, + this.encryptionKey.value + ); + const messageObject = JSON.parse(decryptedMessageObjectString); + if (messageObject.id && messageObject.title) + return this.handleMessageObject(messageObject); + } this.handleMessage({ sender, body: await decryptString(body, this.encryptionKey.value), @@ -459,21 +542,23 @@ this.messages.add(chatMessage); if (selectedChat.value != this) this.hasUnreadMessages.value = true; }; + handleMessageObject = (messageObject) => { + this.addObject(messageObject); + }; + // sending + sendMessagesInOutbox = () => { + this.outbox.value.forEach(async (message) => { + const isSent = await this.sendMessage(message); + if (isSent == true) this.outbox.remove(message); + }); + this.objectOutbox.value.forEach(async (messageObject) => { + const chatMessage = await this.createChatMessage("", messageObject); + const isSent = await this.sendMessage(chatMessage); + if (isSent == true) this.objectOutbox.remove(messageObject.id); + }); + }; // messages - sendNewMessage = async () => { - if (this.cannotSendMessage.value == true) return; - await this.sendMessageText(this.composingMessage.value); - this.composingMessage.value = ""; - }; - sendMessageText = async (text) => { - const message = await this.createMessage(text); - this.sendExistingMessage(message); - }; - resendMessage = (message) => { - if (this.cannotResendMessage.value == true) return; - this.sendMessageText(message.body); - }; - createMessage = async (messageText) => { + createChatMessage = async (messageText, messageObject) => { const secondaryChannelNames = [ ...this.secondaryChannels.value.values() ]; @@ -482,42 +567,128 @@ ...secondaryChannelNames ]; const joinedChannelName = allChannelNames.join("/"); - return { + const chatMessage = { channel: joinedChannelName, sender: senderName.value, body: messageText, isoDate: (/* @__PURE__ */ new Date()).toISOString() }; + if (messageObject) + chatMessage.messageObjectString = await encryptString( + JSON.stringify(messageObject), + this.encryptionKey.value + ); + return chatMessage; }; - sendExistingMessage = async (chatMessage) => { - if (isConnected.value == true && this.isSubscribed.value == true) { - const encrypted = this.encryptionKey.value == "" ? chatMessage.body : await encryptString(chatMessage.body, this.encryptionKey.value); - chatMessage.body = encrypted; - const messageString = JSON.stringify(chatMessage); - UDN.sendMessage(chatMessage.channel, messageString); - } else { - this.outbox.add(chatMessage); - } + sendMessageFromComposer = async () => { + if (this.cannotSendMessage.value == true) return; + await this.sendMessageFromText(this.composingMessage.value); + this.composingMessage.value = ""; }; - sendMessagesInOutbox = () => { - this.outbox.value.forEach((message) => { - this.sendExistingMessage(message); - this.outbox.remove(message); - }); + sendMessageFromText = async (text) => { + const chatMessage = await this.createChatMessage(text); + this.outbox.add(chatMessage); + this.sendMessagesInOutbox(); + }; + resendMessage = (chatMessage) => { + if (this.cannotResendMessage.value == true) return; + this.sendMessageFromText(chatMessage.body); }; clearMessages = () => { this.messages.clear(); }; - deleteMessage = (message) => { - this.messages.remove(message); + deleteMessage = (chatMessage) => { + this.messages.remove(chatMessage); }; - deleteOutboxMessage = (message) => { - this.outbox.remove(message); + deleteOutboxMessage = (chatMessage) => { + this.outbox.remove(chatMessage); }; - decryptReceivedMessage = async (message) => { - message.body = await decryptString(message.body, this.encryptionKey.value); + decryptReceivedMessage = async (chatMessage) => { + chatMessage.body = await decryptString( + chatMessage.body, + this.encryptionKey.value + ); this.messages.callSubscriptions(); }; + sendMessage = async (chatMessage) => { + if (isConnected.value == false || this.isSubscribed.value == false) + return false; + const encryptedBody = this.encryptionKey.value == "" ? chatMessage.body : await encryptString(chatMessage.body, this.encryptionKey.value); + chatMessage.body = encryptedBody; + const messageString = JSON.stringify(chatMessage); + UDN.sendMessage(chatMessage.channel, messageString); + return true; + }; + // objects + createObjectFromTitle = (title) => { + const firstObjectContent = this.createObjectContent(); + const messageObject = { + id: UUID(), + title, + contentVersions: {} + }; + this.addObjectContent(messageObject, firstObjectContent); + return messageObject; + }; + createObjectContent = () => { + return { + id: UUID(), + isoDateVersionCreated: (/* @__PURE__ */ new Date()).toISOString() + }; + }; + addObjectContent = (messageObject, content) => { + messageObject.contentVersions[content.id] = content; + }; + addObject = (messageObject) => { + const existingObject = this.objects.value.get(messageObject.id); + if (existingObject) { + existingObject.title = messageObject.title; + Object.values(messageObject.contentVersions).forEach((content) => { + this.addObjectContent(existingObject, content); + }); + this.objects.set(existingObject.id, existingObject); + } else { + this.objects.set(messageObject.id, messageObject); + } + }; + addObjectAndSend = (messageObject) => { + this.addObject(messageObject); + this.sendObject(messageObject); + }; + sendObject = (messageObject) => { + this.objectOutbox.set(messageObject.id, messageObject); + this.sendMessagesInOutbox(); + }; + deleteObject = (messageObject) => { + this.objects.remove(messageObject.id); + this.objectOutbox.remove(messageObject.id); + }; + resendObjects = () => { + this.objects.value.forEach((messageObject) => { + this.sendObject(messageObject); + }); + }; + clearObjects = () => { + this.objects.clear(); + }; + getSortedContents = (messageObject) => { + const contents = Object.values(messageObject.contentVersions); + contents.sort( + (a, b) => a.isoDateVersionCreated > b.isoDateVersionCreated ? 0 : 1 + ); + return contents; + }; + getMostRecentContentId = (messageObject) => { + const contents = Object.values(messageObject.contentVersions); + return contents[contents.length - 1].id; + }; + getMostRecentContent = (messageObject) => { + const id = this.getMostRecentContentId(messageObject); + return this.getObjectContentFromId(messageObject, id); + }; + getObjectContentFromId = (messageObject, contentId) => { + return messageObject.contentVersions[contentId]; + }; // channel setChannel = () => { if (this.cannotSetChannel.value == true) return; @@ -755,6 +926,7 @@ var chats = new ListState(); var chatIds = restoreListState("chat-ids"); var selectedChat = new State(void 0); + var isShowingChatTools = new State(false); var newChatName = new State(""); var cannotCreateChat = createProxyState( [newChatName], @@ -766,6 +938,7 @@ newChatName.value = ""; } function closeChatView() { + isShowingChatTools.value = false; selectedChat.value = void 0; document.getElementById("settings-tab")?.scrollIntoView(); } @@ -774,6 +947,9 @@ chat.hasUnreadMessages.value = false; document.getElementById("message-tab")?.scrollIntoView(); } + function toggleChatTools() { + isShowingChatTools.value = !isShowingChatTools.value; + } chatIds.value.forEach((id) => chats.add(new Chat(id))); if (serverAddressInput.value != "" && didRequestConnection.value == true) { connect(); @@ -783,9 +959,11 @@ var englishTranslations = { // general set: "Set", + save: "Save", back: "Back", undoChanges: "Undo changes", close: "Close", + discard: "Discard", // overview overview: "Overview", connection: "Connection", @@ -797,7 +975,7 @@ serverAddress: "Server Address", serverAddressPlaceholder: "wss://192.168.0.69:3000", connectToServer: "Connect", - disconnect: "Disonnect", + disconnect: "Disconnect", mailbox: "Mailbox", requestMailbox: "Enable", deleteMailbox: "Disable", @@ -805,7 +983,7 @@ primaryChannel: "Primary channel", primaryChannelPlaceholder: "my-channel", addChat: "Add", - // messages + // chat showChatOptions: "show chat options", configureChatTitle: "Configure Chat", secondaryChannel: "Secondary channel", @@ -815,34 +993,53 @@ encryptionKey: "Encryption key", encryptionKeyPlaceholder: "n10d2482dg283hg", showKey: "Show key", - removeChat: "Remove chat", + clearObjects: "Delete all objects", clearChatMessages: "Clear chat messages", + removeChat: "Remove chat", messageInOutbox: "Pending", noChatSelected: "No chat selected", composerPlaceholder: "Type a message...", sendMessage: "Send", + // messages resendMessage: "Resend message", decryptMessage: "Decrypt message", copyMessage: "Copy message", - deleteMessage: "Delete message" + deleteMessage: "Delete message", + // objects + showObjects: "show objects", + createObject: "create object", + untitledObject: "Untitled Object", + viewAll: "All", + viewNotes: "Notes", + noObjects: "No objects", + noNotes: "No notes", + objectTitle: "Object title", + objectTitlePlaceholder: "My object", + objectVersion: "Object version", + noteContent: "Note", + noteContentPlaceholder: "Take a note...", + resendObjects: "Resend all objects", + deleteObject: "Delete object" }; var allTranslations = { en: englishTranslations, es: { // general set: "Guardar", + save: "Guardar", back: "Atr\xE1s", undoChanges: "Deshacer", close: "Cerrar", + discard: "Descartar", // overview overview: "Resumen", - connection: "Conexion", + connection: "Conexi\xF3n", 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", + serverAddress: "Direcci\xF3n del servidor", serverAddressPlaceholder: "wss://192.168.0.69:3000", connectToServer: "Conectar", disconnect: "Desconectar", @@ -853,33 +1050,48 @@ primaryChannel: "Canal principal", primaryChannelPlaceholder: "mi-canal", addChat: "A\xF1adir", - // messages + // chat showChatOptions: "Mostrar opciones del chat", configureChatTitle: "Configurar chat", - secondaryChannel: "Canal segundario", - secondaryChannelPlaceholder: "A\xF1adir canal segundario", - addSecondaryChannel: "A\xF1adir canal segundario", - removeSecondaryChannel: "Eliminar canal segundario", + secondaryChannel: "Canal secundario", + secondaryChannelPlaceholder: "A\xF1adir canal secundario", + addSecondaryChannel: "A\xF1adir canal secundario", + removeSecondaryChannel: "Eliminar canal secundario", encryptionKey: "Clave de cifrado", encryptionKeyPlaceholder: "n10d2482dg283hg", showKey: "Mostrar clave", + clearObjects: "Eliminar todos los objetos", + clearChatMessages: "Eliminar todos los mensajes", removeChat: "Eliminar chat", - clearChatMessages: "Eliminar todos mensajes", - noChatSelected: "Selecciona un chat", messageInOutbox: "Pendiente", + noChatSelected: "Selecciona un chat", composerPlaceholder: "Escribe un mensaje...", sendMessage: "Enviar", - resendMessage: "Enviar de nuevo", + // messages + resendMessage: "Reenviar mensaje", decryptMessage: "Descifrar mensaje", copyMessage: "Copiar mensaje", - deleteMessage: "Eliminar este mensaje" + deleteMessage: "Eliminar este mensaje", + // objects + showObjects: "mostrar objetos", + createObject: "a\xF1adir nuevo objeto", + untitledObject: "Sin T\xEDtulo", + objectTitle: "T\xEDtulo", + objectTitlePlaceholder: "Mi objeto", + objectVersion: "Version del objeto", + noteContent: "Nota", + noteContentPlaceholder: "Toma nota...", + resendObjects: "Reenviar todos objetos", + deleteObject: "Eliminar objeto" }, de: { // general set: "OK", + save: "Sichern", back: "Zur\xFCck", undoChanges: "\xC4nderungen verwerfen", close: "Schlie\xDFen", + discard: "Verwerfen", // overview overview: "\xDCbersicht", connection: "Verbindung", @@ -899,7 +1111,7 @@ primaryChannel: "Hauptkanal", primaryChannelPlaceholder: "mein-kanal", addChat: "Hinzuf\xFCgen", - // messages + // chat showChatOptions: "Chatoptionen einblenden", configureChatTitle: "Chat konfigurieren", secondaryChannel: "Zweitkanal", @@ -909,22 +1121,232 @@ encryptionKey: "Schl\xFCssel", encryptionKeyPlaceholder: "n10d2482dg283hg", showKey: "Schl\xFCssel anzeigen", - removeChat: "Chat l\xF6schen", + clearObjects: "Alle Objekte l\xF6schen", clearChatMessages: "Nachrichtenverlauf leeren", - noChatSelected: "Kein Chat ausgew\xE4hlt", + removeChat: "Chat l\xF6schen", messageInOutbox: "Ausstehend", + noChatSelected: "Kein Chat ausgew\xE4hlt", composerPlaceholder: "Neue Nachricht...", sendMessage: "Senden", + // messages resendMessage: "Erneut senden", decryptMessage: "Nachricht entschl\xFCsseln", copyMessage: "Nachricht kopieren", - deleteMessage: "Nachricht l\xF6schen" + deleteMessage: "Nachricht l\xF6schen", + // objects + showObjects: "Objekte anzeigen", + createObject: "Neues Objekt erstellen", + untitledObject: "Unbenannt", + objectTitle: "Titel", + objectTitlePlaceholder: "Mein Objekt", + objectVersion: "Version des Objekts", + noteContent: "Notiz", + noteContentPlaceholder: "Notiz eingeben...", + resendObjects: "Objekte erneut senden", + deleteObject: "Objekt l\xF6schen" } }; var language = navigator.language.substring(0, 2); var translation = allTranslations[language] ?? allTranslations.en; - // src/Views/chatOptionModal.tsx + // src/Views/Objects/allObjectsView.tsx + function AllObjectsView(chat, selectedObject, isShowingObjectModal) { + const objectConverter = (messageObject) => { + function select() { + selectedObject.value = messageObject; + isShowingObjectModal.value = true; + } + return /* @__PURE__ */ createElement("button", { class: "tile", "on:click": select }, /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("b", null, messageObject.title), /* @__PURE__ */ createElement("span", { class: "secondary" }, messageObject.id))); + }; + const content = createProxyState( + [chat.objects], + () => chat.objects.value.size == 0 ? /* @__PURE__ */ createElement("div", { class: "flex-column width-100 height-100 align-center justify-center secondary" }, translation.noObjects) : /* @__PURE__ */ createElement( + "div", + { + class: "grid gap padding", + style: "grid-template-columns: repeat(auto-fill, minmax(350px, 1fr))", + "children:prepend": [chat.objects, objectConverter] + } + ) + ); + return /* @__PURE__ */ createElement("div", { class: "width-100 height-100", "children:set": content }); + } + + // src/Views/Objects/noteObjectsView.tsx + function NoteObjectsView(chat, selectedObject, isShowingObjectModal) { + const objectConverter = (messageObject) => { + const latest = chat.getMostRecentContent(messageObject); + function select() { + selectedObject.value = messageObject; + isShowingObjectModal.value = true; + } + return /* @__PURE__ */ createElement("button", { class: "tile", "on:click": select }, /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("b", null, messageObject.title), /* @__PURE__ */ createElement("span", { class: "secondary ellipsis" }, latest.noteContent.split("\n")[0]))); + }; + const notes = new ListState(); + chat.objects.subscribe(() => { + notes.clear(); + chat.objects.value.forEach((messageObject) => { + const latest = chat.getMostRecentContent(messageObject); + if (!latest.noteContent) return; + notes.add(messageObject); + }); + }); + const content = createProxyState( + [chat.objects], + () => notes.value.size == 0 ? /* @__PURE__ */ createElement("div", { class: "flex-column width-100 height-100 align-center justify-center secondary" }, translation.noNotes) : /* @__PURE__ */ createElement( + "div", + { + class: "grid gap padding", + style: "grid-template-columns: repeat(auto-fill, minmax(350px, 1fr))", + "children:prepend": [notes, objectConverter] + } + ) + ); + return /* @__PURE__ */ createElement("div", { class: "width-100 height-100", "children:set": content }); + } + + // src/Views/Objects/objectDetailModal.tsx + function ObjectDetailModal(chat, messageObject, isPresented) { + const editingTitle = new State(messageObject.title); + const selectedMessageObjectId = new State( + chat.getMostRecentContentId(messageObject) + ); + const selectedMessageObject = createProxyState( + [selectedMessageObjectId], + () => chat.getObjectContentFromId(messageObject, selectedMessageObjectId.value) + ); + const didEditContent = new State(false); + const editingNoteContent = createProxyState( + [selectedMessageObject], + () => selectedMessageObject.value.noteContent ?? "" + ); + bulkSubscribe( + [editingNoteContent], + () => didEditContent.value = true + ); + function handleKeyDown(e) { + if (!e.metaKey && !e.ctrlKey) return; + switch (e.key) { + case "s": + e.preventDefault(); + saveAndClose(); + break; + } + } + function closeModal() { + isPresented.value = false; + } + function saveAndClose() { + messageObject.title = editingTitle.value; + if (didEditContent.value == true) { + chat.addObjectContent(messageObject, { + isoDateVersionCreated: (/* @__PURE__ */ new Date()).toISOString(), + id: UUID(), + noteContent: editingNoteContent.value + }); + } + chat.addObjectAndSend(messageObject); + closeModal(); + } + function deleteAndClose() { + chat.deleteObject(messageObject); + closeModal(); + } + return /* @__PURE__ */ createElement("div", { class: "modal", "toggle:open": isPresented, "on:keydown": handleKeyDown }, /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("main", null, /* @__PURE__ */ createElement("h2", null, messageObject.title), /* @__PURE__ */ createElement("span", { class: "secondary" }, messageObject.id), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "label"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.objectTitle), /* @__PURE__ */ createElement( + "input", + { + autofocus: true, + "bind:value": editingTitle, + placeholder: translation.objectTitlePlaceholder + } + ))), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "history"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.objectVersion), /* @__PURE__ */ createElement("select", { "bind:value": selectedMessageObjectId }, ...chat.getSortedContents(messageObject).map((content) => /* @__PURE__ */ createElement("option", { value: content.id }, new Date( + content.isoDateVersionCreated + ).toLocaleString()))), /* @__PURE__ */ createElement("span", { class: "icon" }, "arrow_drop_down"))), /* @__PURE__ */ createElement("label", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "sticky_note_2"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translation.noteContent), /* @__PURE__ */ createElement( + "textarea", + { + rows: "5", + "bind:value": editingNoteContent, + placeholder: translation.noteContentPlaceholder + } + ))), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("button", { class: "danger width-input", "on:click": deleteAndClose }, translation.deleteObject, /* @__PURE__ */ createElement("span", { class: "icon" }, "delete")))), /* @__PURE__ */ createElement("div", { class: "flex-row" }, /* @__PURE__ */ createElement("button", { class: "flex-1 width-100 danger", "on:click": closeModal }, translation.discard), /* @__PURE__ */ createElement("button", { class: "flex-1 width-100 primary", "on:click": saveAndClose }, translation.save, /* @__PURE__ */ createElement("span", { class: "icon" }, "save"))))); + } + + // src/Views/Objects/chatObjectView.tsx + var viewTypes = { + all: [translation.viewAll, "grid_view"], + notes: [translation.viewNotes, "sticky_note_2"] + }; + function ChatObjectView(chat) { + const isShowingObjectModal = new State(false); + const selectedObject = new State(void 0); + const objectModal = createProxyState( + [chat.objects, selectedObject], + () => { + if (selectedObject.value == void 0) return /* @__PURE__ */ createElement("div", null); + return ObjectDetailModal( + chat, + selectedObject.value, + isShowingObjectModal + ); + } + ); + const selectedViewType = new State("all"); + const mainView = createProxyState([selectedViewType], () => { + function getViewFunction() { + switch (selectedViewType.value) { + case "notes": + return NoteObjectsView; + default: + return AllObjectsView; + } + } + return getViewFunction()(chat, selectedObject, isShowingObjectModal); + }); + function createObject() { + const newObject = chat.createObjectFromTitle(translation.untitledObject); + chat.addObjectAndSend(newObject); + selectedObject.value = newObject; + isShowingObjectModal.value = true; + } + return /* @__PURE__ */ createElement("div", { class: "chat-object-view flex-column" }, /* @__PURE__ */ createElement("div", { class: "flex-row align-center border-bottom" }, /* @__PURE__ */ createElement( + "button", + { + class: "primary height-100", + "on:click": createObject, + "aria-label": translation.createObject + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "add") + ), /* @__PURE__ */ createElement("div", { class: "padding-sm flex flex-row gap justify-center scroll-h width-100" }, ...Object.keys(viewTypes).map( + (key) => ViewTypeToggle(key, selectedViewType) + )), /* @__PURE__ */ createElement( + "button", + { + class: "height-100", + "on:click": createObject, + "aria-label": translation.createObject + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "visibility") + )), /* @__PURE__ */ createElement( + "div", + { + class: "width-100 height-100 flex-1 scroll-h", + "children:set": mainView + } + ), /* @__PURE__ */ createElement("div", { "children:set": objectModal })); + } + function ViewTypeToggle(key, selection) { + const [label, icon] = viewTypes[key]; + function select() { + selection.value = key; + } + const isSelected = createProxyState( + [selection], + () => selection.value == key + ); + return /* @__PURE__ */ createElement("button", { "aria-label": label, "on:click": select, "toggle:selected": isSelected }, /* @__PURE__ */ createElement("span", { class: "icon" }, icon)); + } + + // src/Views/Chat/chatOptionModal.tsx function ChatOptionModal(chat, isPresented) { function closeModal() { isPresented.value = false; @@ -1011,7 +1433,16 @@ "bind:value": chat.encryptionKey, "set:type": inputType } - ))), /* @__PURE__ */ createElement("label", { class: "inline margin-0" }, /* @__PURE__ */ createElement("input", { type: "checkbox", "bind:checked": shouldShowKey }), translation.showKey)), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, /* @__PURE__ */ createElement( + ))), /* @__PURE__ */ createElement("label", { class: "inline margin-0" }, /* @__PURE__ */ createElement("input", { type: "checkbox", "bind:checked": shouldShowKey }), translation.showKey)), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap width-input" }, /* @__PURE__ */ createElement("button", { "on:click": chat.resendObjects }, translation.resendObjects, /* @__PURE__ */ createElement("span", { class: "icon" }, "replay"))), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap width-input" }, /* @__PURE__ */ createElement( + "button", + { + class: "danger", + "on:click": chat.clearObjects, + "toggle:disabled": chat.cannotClearObjects + }, + translation.clearObjects, + /* @__PURE__ */ createElement("span", { class: "icon" }, "deployed_code") + ), /* @__PURE__ */ createElement( "button", { class: "danger", @@ -1019,11 +1450,11 @@ "toggle:disabled": chat.cannotClearMessages }, 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")))); + /* @__PURE__ */ createElement("span", { class: "icon" }, "chat_error") + ), /* @__PURE__ */ createElement("button", { class: "danger", "on:click": deleteChat }, translation.removeChat, /* @__PURE__ */ createElement("span", { class: "icon" }, "delete_forever")))), /* @__PURE__ */ createElement("button", { "on:click": closeModal }, translation.close, /* @__PURE__ */ createElement("span", { class: "icon" }, "close")))); } - // src/Views/messageComposer.tsx + // src/Views/Chat/messageComposer.tsx function MessageComposer(chat) { return /* @__PURE__ */ createElement("div", { class: "flex-row width-100" }, " ", /* @__PURE__ */ createElement( "input", @@ -1032,20 +1463,20 @@ style: "max-width: unset", placeholder: translation.composerPlaceholder, "bind:value": chat.composingMessage, - "on:enter": chat.sendNewMessage + "on:enter": chat.sendMessageFromComposer } ), /* @__PURE__ */ createElement( "button", { class: "primary", - "on:click": chat.sendNewMessage, + "on:click": chat.sendMessageFromComposer, "toggle:disabled": chat.cannotSendMessage }, /* @__PURE__ */ createElement("span", { class: "icon" }, "send") )); } - // src/Views/threadView.tsx + // src/Views/Chat/threadView.tsx function ThreadView(chat) { const messageConverter = (message) => { function resendMessage() { @@ -1108,15 +1539,19 @@ "children:append": [chat.outbox, outboxMessageConverter] } ); - const listWrapper = /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, messageList, outboxList); + const listWrapper = /* @__PURE__ */ createElement("div", { class: "thread-view flex-column gap" }, messageList, outboxList); function scrollToBottom() { - const scrollFromBottom = listWrapper.scrollHeight - (listWrapper.scrollTop + listWrapper.offsetHeight); - if (scrollFromBottom > 400) return; listWrapper.scrollTop = listWrapper.scrollHeight; } - chat.messages.handleAddition(scrollToBottom); - chat.outbox.handleAddition(scrollToBottom); - setTimeout(() => listWrapper.scrollTop = listWrapper.scrollHeight, 50); + function scrollToBottomIfAppropriate() { + const scrollFromBottom = listWrapper.scrollHeight - (listWrapper.scrollTop + listWrapper.offsetHeight); + if (scrollFromBottom > 400) return; + scrollToBottom(); + } + chat.messages.handleAddition(scrollToBottomIfAppropriate); + chat.outbox.handleAddition(scrollToBottomIfAppropriate); + isShowingChatTools.subscribe(() => scrollToBottom()); + setTimeout(() => scrollToBottom(), 50); return listWrapper; } @@ -1132,6 +1567,14 @@ } 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.showObjects, + "on:click": toggleChatTools, + "toggle:selected": isShowingChatTools + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "deployed_code") + ), /* @__PURE__ */ createElement( "button", { "aria-label": translation.showChatOptions, @@ -1139,15 +1582,23 @@ }, /* @__PURE__ */ createElement("span", { class: "icon" }, "tune") ))), + ChatObjectView(chat), ThreadView(chat), /* @__PURE__ */ createElement("footer", null, MessageComposer(chat)), ChatOptionModal(chat, isShowingOptions) ]; }); - return /* @__PURE__ */ createElement("article", { id: "message-tab", "children:set": messageTabContent }); + return /* @__PURE__ */ createElement( + "article", + { + "toggle:showingchattools": isShowingChatTools, + id: "message-tab", + "children:set": messageTabContent + } + ); } - // src/Views/chatListSection.tsx + // src/Views/Overview/chatListSection.tsx var chatConverter = (chat) => { function select() { selectChat(chat); @@ -1194,7 +1645,7 @@ )); } - // src/Views/connectionSection.tsx + // src/Views/Overview/connectionSection.tsx var addressConverter = (address) => { return /* @__PURE__ */ createElement("option", null, address); }; @@ -1261,7 +1712,7 @@ ))); } - // src/Views/personalSection.tsx + // src/Views/Overview/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", diff --git a/dist/styles.css b/dist/styles.css index 30028c98..c9453212 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -140,10 +140,12 @@ select, @media (hover: hover) { button:hover, select:hover, - button.tile.ghost:hover, - [selected]:active { + button.tile.ghost:hover { background-color: var(--surface-hover); } + [selected]:hover { + background-color: var(--surface); + } } button:active, select:active, @@ -151,6 +153,9 @@ button.tile.ghost:active, [selected] { background-color: var(--surface-active); } +[selected]:active { + background-color: var(--surface-hover); +} header button { background-color: transparent; } @@ -318,6 +323,10 @@ hr { border-bottom: 1px solid var(--border); } +.border-bottom { + border-bottom: 1px solid var(--border) !important; +} + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):focus:not( label.tile * ), @@ -480,7 +489,7 @@ input[type="radio"], } .justify-center { - justify-content: center !important; + justify-content: safe center !important; } .justify-end { @@ -577,6 +586,10 @@ textarea, padding: var(--control-padding) !important; } +.padding-sm { + padding: var(--gap-between-tiles) !important; +} + .modal main { padding: var(--modal-padding); } @@ -597,7 +610,7 @@ label.tile:has(> span.icon) { padding-left: var(--control-padding); } -label.tile:has(> span.icon) input { +label.tile:has(> span.icon) :is(input, select, textarea) { padding-left: 0px; } @@ -730,8 +743,7 @@ button:not(.tile):not(header *), footer, input:not([type="checkbox"]):not([type="radio"]), select, -select + .icon, -textarea { +select + .icon { height: var(--control-height); } @@ -781,6 +793,10 @@ input[type="radio"] { height: 16px; } +label.tile textarea { + resize: vertical; +} + /* text */ @@ -952,6 +968,14 @@ main { overflow-x: scroll; } +.scroll-h { + overflow-x: scroll !important; +} + +.scroll-v { + overflow-y: scroll !important; +} + .ellipsis { text-overflow: ellipsis; } diff --git a/package.json b/package.json index 996bd862..b5149311 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "bloatless-react": "^1.3.1-beta1", + "bloatless-react": "^1.3.7", "esbuild": "latest", "udn-frontend": "^1.0.7-beta1" }, diff --git a/src/Model/chatModel.ts b/src/Model/chatModel.ts index ecc5d2d1..1cfbed41 100644 --- a/src/Model/chatModel.ts +++ b/src/Model/chatModel.ts @@ -21,6 +21,22 @@ export interface ChatMessage { sender: string; body: string; isoDate: string; + + messageObjectString?: string; +} + +// object +export interface MessageObject { + id: string; + title: string; + contentVersions: { [key: string]: MessageObjectContent }; +} + +export interface MessageObjectContent { + isoDateVersionCreated: string; + id: string; + + noteContent?: string; } // chat @@ -34,7 +50,9 @@ export class Chat { encryptionKey: React.State; messages: React.ListState; + objects: React.MapState; outbox: React.ListState; + objectOutbox: React.MapState; isOutBoxEmpty: React.State; composingMessage: React.State; @@ -47,6 +65,7 @@ export class Chat { cannotSetChannel: React.State; cannotUndoChannel: React.State; cannotClearMessages: React.State; + cannotClearObjects: React.State; // init constructor(id: string = React.UUID()) { @@ -68,7 +87,9 @@ export class Chat { ); this.encryptionKey = React.restoreState(storageKeys.encyptionKey(id), ""); this.messages = React.restoreListState(storageKeys.messages(id)); + this.objects = React.restoreMapState(storageKeys.objects(id)); this.outbox = React.restoreListState(storageKeys.outbox(id)); + this.objectOutbox = React.restoreMapState(storageKeys.itemOutbox(id)); this.isOutBoxEmpty = React.createProxyState( [this.outbox], () => this.outbox.value.size == 0 @@ -112,6 +133,10 @@ export class Chat { [this.messages], () => this.messages.value.size == 0 ); + this.cannotClearObjects = React.createProxyState( + [this.objects], + () => this.objects.value.size == 0 + ); } // general @@ -131,9 +156,21 @@ export class Chat { 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); + + const message: ChatMessage = JSON.parse(data.messageBody); + const { sender, body, channel, isoDate, messageObjectString } = message; + + if (messageObjectString) { + const decryptedMessageObjectString = await decryptString( + messageObjectString, + this.encryptionKey.value + ); + const messageObject = JSON.parse(decryptedMessageObjectString); + if (messageObject.id && messageObject.title) + return this.handleMessageObject(messageObject); + } + this.handleMessage({ sender, body: await decryptString(body, this.encryptionKey.value), @@ -155,25 +192,28 @@ export class Chat { if (selectedChat.value != this) this.hasUnreadMessages.value = true; }; + handleMessageObject = (messageObject: MessageObject): void => { + this.addObject(messageObject); + }; + + // sending + sendMessagesInOutbox = () => { + this.outbox.value.forEach(async (message) => { + const isSent = await this.sendMessage(message); + if (isSent == true) this.outbox.remove(message); + }); + this.objectOutbox.value.forEach(async (messageObject) => { + const chatMessage = await this.createChatMessage("", messageObject); + const isSent = await this.sendMessage(chatMessage); + if (isSent == true) this.objectOutbox.remove(messageObject.id); + }); + }; + // messages - sendNewMessage = async (): Promise => { - if (this.cannotSendMessage.value == true) return; - - await this.sendMessageText(this.composingMessage.value); - this.composingMessage.value = ""; - }; - - sendMessageText = async (text: string): Promise => { - const message = await this.createMessage(text); - this.sendExistingMessage(message); - }; - - resendMessage = (message: ChatMessage) => { - if (this.cannotResendMessage.value == true) return; - this.sendMessageText(message.body); - }; - - createMessage = async (messageText: string): Promise => { + createChatMessage = async ( + messageText: string, + messageObject?: MessageObject + ): Promise => { // get channels const secondaryChannelNames: string[] = [ ...this.secondaryChannels.value.values(), @@ -185,54 +225,167 @@ export class Chat { // create object const joinedChannelName = allChannelNames.join("/"); - return { + const chatMessage: ChatMessage = { channel: joinedChannelName, sender: senderName.value, body: messageText, isoDate: new Date().toISOString(), }; + + if (messageObject) + chatMessage.messageObjectString = await encryptString( + JSON.stringify(messageObject), + this.encryptionKey.value + ); + return chatMessage; }; - sendExistingMessage = async (chatMessage: ChatMessage): Promise => { - if (isConnected.value == true && this.isSubscribed.value == true) { - const encrypted = - this.encryptionKey.value == "" - ? chatMessage.body - : await encryptString(chatMessage.body, this.encryptionKey.value); + sendMessageFromComposer = async (): Promise => { + if (this.cannotSendMessage.value == true) return; - chatMessage.body = encrypted; - - const messageString = JSON.stringify(chatMessage); - UDN.sendMessage(chatMessage.channel, messageString); - } else { - this.outbox.add(chatMessage); - } + await this.sendMessageFromText(this.composingMessage.value); + this.composingMessage.value = ""; }; - sendMessagesInOutbox = () => { - this.outbox.value.forEach((message) => { - this.sendExistingMessage(message); - this.outbox.remove(message); - }); + sendMessageFromText = async (text: string): Promise => { + const chatMessage = await this.createChatMessage(text); + this.outbox.add(chatMessage); + this.sendMessagesInOutbox(); + }; + + resendMessage = (chatMessage: ChatMessage) => { + if (this.cannotResendMessage.value == true) return; + this.sendMessageFromText(chatMessage.body); }; clearMessages = (): void => { this.messages.clear(); }; - deleteMessage = (message: ChatMessage): void => { - this.messages.remove(message); + deleteMessage = (chatMessage: ChatMessage): void => { + this.messages.remove(chatMessage); }; - deleteOutboxMessage = (message: ChatMessage): void => { - this.outbox.remove(message); + deleteOutboxMessage = (chatMessage: ChatMessage): void => { + this.outbox.remove(chatMessage); }; - decryptReceivedMessage = async (message: ChatMessage): Promise => { - message.body = await decryptString(message.body, this.encryptionKey.value); + decryptReceivedMessage = async (chatMessage: ChatMessage): Promise => { + chatMessage.body = await decryptString( + chatMessage.body, + this.encryptionKey.value + ); this.messages.callSubscriptions(); }; + sendMessage = async (chatMessage: ChatMessage): Promise => { + if (isConnected.value == false || this.isSubscribed.value == false) + return false; + + const encryptedBody = + this.encryptionKey.value == "" + ? chatMessage.body + : await encryptString(chatMessage.body, this.encryptionKey.value); + + chatMessage.body = encryptedBody; + + const messageString = JSON.stringify(chatMessage); + UDN.sendMessage(chatMessage.channel, messageString); + + return true; + }; + + // objects + createObjectFromTitle = (title: string): MessageObject => { + const firstObjectContent = this.createObjectContent(); + const messageObject: MessageObject = { + id: React.UUID(), + title, + contentVersions: {}, + }; + this.addObjectContent(messageObject, firstObjectContent); + return messageObject; + }; + + createObjectContent = () => { + return { + id: React.UUID(), + isoDateVersionCreated: new Date().toISOString(), + }; + }; + + addObjectContent = ( + messageObject: MessageObject, + content: MessageObjectContent + ): void => { + messageObject.contentVersions[content.id] = content; + }; + + addObject = (messageObject: MessageObject): void => { + const existingObject = this.objects.value.get(messageObject.id); + if (existingObject) { + existingObject.title = messageObject.title; + Object.values(messageObject.contentVersions).forEach((content) => { + this.addObjectContent(existingObject, content); + }); + this.objects.set(existingObject.id, existingObject); + } else { + this.objects.set(messageObject.id, messageObject); + } + }; + + addObjectAndSend = (messageObject: MessageObject): void => { + this.addObject(messageObject); + this.sendObject(messageObject); + }; + + sendObject = (messageObject: MessageObject): void => { + this.objectOutbox.set(messageObject.id, messageObject); + this.sendMessagesInOutbox(); + }; + + deleteObject = (messageObject: MessageObject): void => { + this.objects.remove(messageObject.id); + this.objectOutbox.remove(messageObject.id); + }; + + resendObjects = (): void => { + this.objects.value.forEach((messageObject) => { + this.sendObject(messageObject); + }); + }; + + clearObjects = (): void => { + this.objects.clear(); + }; + + getSortedContents = ( + messageObject: MessageObject + ): MessageObjectContent[] => { + const contents = Object.values(messageObject.contentVersions); + contents.sort((a, b) => + a.isoDateVersionCreated > b.isoDateVersionCreated ? 0 : 1 + ); + return contents; + }; + + getMostRecentContentId = (messageObject: MessageObject): string => { + const contents = Object.values(messageObject.contentVersions); + return contents[contents.length - 1].id; + }; + + getMostRecentContent = (messageObject: MessageObject): MessageObjectContent => { + const id = this.getMostRecentContentId(messageObject); + return this.getObjectContentFromId(messageObject, id); + } + + getObjectContentFromId = ( + messageObject: MessageObject, + contentId: string + ): MessageObjectContent => { + return messageObject.contentVersions[contentId]; + }; + // channel setChannel = (): void => { if (this.cannotSetChannel.value == true) return; diff --git a/src/Model/model.ts b/src/Model/model.ts index c4fdf655..d70335b5 100644 --- a/src/Model/model.ts +++ b/src/Model/model.ts @@ -139,6 +139,7 @@ export const chats = new React.ListState(); export const chatIds = React.restoreListState("chat-ids"); export const selectedChat = new React.State(undefined); +export const isShowingChatTools = new React.State(false); export const newChatName = new React.State(""); export const cannotCreateChat = React.createProxyState( @@ -154,6 +155,7 @@ export function createChat() { } export function closeChatView() { + isShowingChatTools.value = false; selectedChat.value = undefined; document.getElementById("settings-tab")?.scrollIntoView(); } @@ -164,6 +166,10 @@ export function selectChat(chat: Chat) { document.getElementById("message-tab")?.scrollIntoView(); } +export function toggleChatTools() { + isShowingChatTools.value = !isShowingChatTools.value; +} + chatIds.value.forEach((id) => chats.add(new Chat(id))); // INIT diff --git a/src/Tabs/messageTab.tsx b/src/Tabs/messageTab.tsx index 3c531d08..6fde89f5 100644 --- a/src/Tabs/messageTab.tsx +++ b/src/Tabs/messageTab.tsx @@ -1,10 +1,11 @@ import * as React from "bloatless-react"; -import { closeChatView, selectedChat } from "../Model/model"; +import { closeChatView, isShowingChatTools, selectedChat, toggleChatTools } from "../Model/model"; -import { ChatOptionModal } from "../Views/chatOptionModal"; -import { MessageComposer } from "../Views/messageComposer"; -import { ThreadView } from "../Views/threadView"; +import { ChatObjectView } from "../Views/Objects/chatObjectView"; +import { ChatOptionModal } from "../Views/Chat/chatOptionModal"; +import { MessageComposer } from "../Views/Chat/messageComposer"; +import { ThreadView } from "../Views/Chat/threadView"; import { translation } from "../translations"; export function MessageTab() { @@ -33,6 +34,13 @@ export function MessageTab() { + + + +
+ +
+ +
diff --git a/src/Views/messageComposer.tsx b/src/Views/Chat/messageComposer.tsx similarity index 72% rename from src/Views/messageComposer.tsx rename to src/Views/Chat/messageComposer.tsx index 66a87fed..61b839ca 100644 --- a/src/Views/messageComposer.tsx +++ b/src/Views/Chat/messageComposer.tsx @@ -1,7 +1,7 @@ import * as React from "bloatless-react"; -import { Chat } from "../Model/chatModel"; -import { translation } from "../translations"; +import { Chat } from "../../Model/chatModel"; +import { translation } from "../../translations"; export function MessageComposer(chat: Chat) { return ( @@ -12,11 +12,11 @@ export function MessageComposer(chat: Chat) { style="max-width: unset" placeholder={translation.composerPlaceholder} bind:value={chat.composingMessage} - on:enter={chat.sendNewMessage} + on:enter={chat.sendMessageFromComposer} > + ); + }; + + const content = React.createProxyState([chat.objects], () => + chat.objects.value.size == 0 ? ( +
+ {translation.noObjects} +
+ ) : ( +
+ ) + ); + + return
; +} diff --git a/src/Views/Objects/chatObjectView.tsx b/src/Views/Objects/chatObjectView.tsx new file mode 100644 index 00000000..e2fc6dcd --- /dev/null +++ b/src/Views/Objects/chatObjectView.tsx @@ -0,0 +1,111 @@ +import * as React from "bloatless-react"; + +import { Chat, MessageObject } from "../../Model/chatModel"; + +import { AllObjectsView } from "./allObjectsView"; +import { NoteObjectsView } from "./noteObjectsView"; +import { ObjectDetailModal } from "./objectDetailModal"; +import { translation } from "../../translations"; + +export const viewTypes = { + all: [translation.viewAll, "grid_view"], + notes: [translation.viewNotes, "sticky_note_2"], +}; + +export function ChatObjectView(chat: Chat) { + // selection and modal + const isShowingObjectModal = new React.State(false); + const selectedObject = new React.State(undefined); + const objectModal = React.createProxyState( + [chat.objects, selectedObject], + () => { + if (selectedObject.value == undefined) return
; + + return ObjectDetailModal( + chat, + selectedObject.value, + isShowingObjectModal + ); + } + ); + + // view type + const selectedViewType = new React.State("all"); + const mainView = React.createProxyState([selectedViewType], () => { + function getViewFunction() { + switch (selectedViewType.value) { + case "notes": + return NoteObjectsView; + default: + return AllObjectsView; + } + } + + return getViewFunction()(chat, selectedObject, isShowingObjectModal); + }); + + // methods + function createObject() { + const newObject = chat.createObjectFromTitle(translation.untitledObject); + chat.addObjectAndSend(newObject); + selectedObject.value = newObject; + isShowingObjectModal.value = true; + } + + return ( +
+
+ + +
+ {...Object.keys(viewTypes).map((key: any) => + ViewTypeToggle(key, selectedViewType) + )} +
+ + +
+ +
+ +
+
+ ); +} + +function ViewTypeToggle( + key: keyof typeof viewTypes, + selection: React.State +) { + const [label, icon] = viewTypes[key]; + + function select() { + selection.value = key; + } + + const isSelected = React.createProxyState( + [selection], + () => selection.value == key + ); + + return ( + + ); +} diff --git a/src/Views/Objects/noteObjectsView.tsx b/src/Views/Objects/noteObjectsView.tsx new file mode 100644 index 00000000..82fbb41c --- /dev/null +++ b/src/Views/Objects/noteObjectsView.tsx @@ -0,0 +1,60 @@ +import * as React from "bloatless-react"; + +import { Chat, MessageObject } from "../../Model/chatModel"; + +import { translation } from "../../translations"; + +export function NoteObjectsView( + chat: Chat, + selectedObject: React.State, + isShowingObjectModal: React.State +) { + const objectConverter: React.StateItemConverter = ( + messageObject + ) => { + const latest = chat.getMostRecentContent(messageObject); + + function select() { + selectedObject.value = messageObject; + isShowingObjectModal.value = true; + } + + return ( + + ); + }; + + const notes = new React.ListState(); + chat.objects.subscribe(() => { + notes.clear(); + chat.objects.value.forEach((messageObject) => { + const latest = chat.getMostRecentContent(messageObject); + if (!latest.noteContent) return; + + notes.add(messageObject); + }); + }); + + const content = React.createProxyState([chat.objects], () => + notes.value.size == 0 ? ( +
+ {translation.noNotes} +
+ ) : ( +
+ ) + ); + + return
; +} diff --git a/src/Views/Objects/objectDetailModal.tsx b/src/Views/Objects/objectDetailModal.tsx new file mode 100644 index 00000000..6dbb1a01 --- /dev/null +++ b/src/Views/Objects/objectDetailModal.tsx @@ -0,0 +1,147 @@ +import * as React from "bloatless-react"; + +import { Chat, MessageObject } from "../../Model/chatModel"; + +import { translation } from "../../translations"; + +export function ObjectDetailModal( + chat: Chat, + messageObject: MessageObject, + isPresented: React.State +) { + // state + const editingTitle = new React.State(messageObject.title); + + const selectedMessageObjectId = new React.State( + chat.getMostRecentContentId(messageObject) + ); + const selectedMessageObject = React.createProxyState( + [selectedMessageObjectId], + () => + chat.getObjectContentFromId(messageObject, selectedMessageObjectId.value) + ); + + const didEditContent = new React.State(false); + const editingNoteContent = React.createProxyState( + [selectedMessageObject], + () => selectedMessageObject.value.noteContent ?? "" + ); + React.bulkSubscribe( + [editingNoteContent], + () => (didEditContent.value = true) + ); + + // methods + function handleKeyDown(e: KeyboardEvent) { + if (!e.metaKey && !e.ctrlKey) return; + + switch (e.key) { + case "s": + e.preventDefault(); + saveAndClose(); + break; + } + } + + function closeModal() { + isPresented.value = false; + } + + function saveAndClose() { + messageObject.title = editingTitle.value; + + if (didEditContent.value == true) { + chat.addObjectContent(messageObject, { + isoDateVersionCreated: new Date().toISOString(), + id: React.UUID(), + + noteContent: editingNoteContent.value, + }); + } + + chat.addObjectAndSend(messageObject); + closeModal(); + } + + function deleteAndClose() { + chat.deleteObject(messageObject); + closeModal(); + } + + return ( + + ); +} diff --git a/src/Views/chatListSection.tsx b/src/Views/Overview/chatListSection.tsx similarity index 88% rename from src/Views/chatListSection.tsx rename to src/Views/Overview/chatListSection.tsx index e70fc054..3e57b4bc 100644 --- a/src/Views/chatListSection.tsx +++ b/src/Views/Overview/chatListSection.tsx @@ -7,12 +7,12 @@ import { newChatName, selectChat, selectedChat, -} from "../Model/model"; +} from "../../Model/model"; -import { Chat } from "../Model/chatModel"; -import { translation } from "../translations"; +import { Chat } from "../../Model/chatModel"; +import { translation } from "../../translations"; -const chatConverter: React.ListItemConverter = (chat) => { +const chatConverter: React.StateItemConverter = (chat) => { function select() { selectChat(chat); } diff --git a/src/Views/connectionSection.tsx b/src/Views/Overview/connectionSection.tsx similarity index 94% rename from src/Views/connectionSection.tsx rename to src/Views/Overview/connectionSection.tsx index 92460023..5df139ac 100644 --- a/src/Views/connectionSection.tsx +++ b/src/Views/Overview/connectionSection.tsx @@ -13,11 +13,11 @@ import { requestMailbox, resetAddressInput, serverAddressInput, -} from "../Model/model"; +} from "../../Model/model"; -import { translation } from "../translations"; +import { translation } from "../../translations"; -const addressConverter: React.ListItemConverter = (address) => { +const addressConverter: React.StateItemConverter = (address) => { return ; }; diff --git a/src/Views/personalSection.tsx b/src/Views/Overview/personalSection.tsx similarity index 81% rename from src/Views/personalSection.tsx rename to src/Views/Overview/personalSection.tsx index 8fe8db75..cab91625 100644 --- a/src/Views/personalSection.tsx +++ b/src/Views/Overview/personalSection.tsx @@ -1,7 +1,7 @@ import * as React from "bloatless-react"; -import { senderName } from "../Model/model"; -import { translation } from "../translations"; +import { senderName } from "../../Model/model"; +import { translation } from "../../translations"; export function PersonalSection() { return ( diff --git a/src/styles.css b/src/styles.css index df744af6..9f47312b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,3 +1,89 @@ [unread] { border-left: 10px solid var(--text-warn); } + +/* TOOLS */ +:root { + --thread-view-height-with-tools: 8rem; + --thread-view-width-with-tools: 20rem; + --footer-top-line: calc(var(--control-height) + var(--safe-area-bottom)); +} + +#message-tab { + flex-direction: column; +} + +.message-tab-content-wrapper { + display: flex; + flex-direction: column; +} + +.chat-object-view { + display: none; +} + +[showingchattools] :is(.thread-view, .chat-object-view) { + overflow: scroll; + position: absolute; + + padding: 0px; +} + +[showingchattools] header { + border-bottom: 1px solid var(--border); +} + +[showingchattools] .chat-object-view { + display: flex; + + top: var(--header-height); + bottom: calc(var(--thread-view-height-with-tools) + var(--footer-top-line)); + + border-bottom: 1px solid var(--border); +} + +[showingchattools] .thread-view { + display: flex; + + height: var(--thread-view-height-with-tools); + + top: unset; + bottom: var(--footer-top-line); + + flex: 1; +} + +@media (min-width: 640px) and (max-width: 1400px) { + main.split:has([showingchattools]) { + display: flex; + } + + main.split:has([showingchattools]) #settings-tab { + display: none; + } +} + +@media (min-width: 640px) { + [showingchattools] :is(.thread-view, .chat-object-view) { + top: var(--header-height); + bottom: var(--footer-top-line); + } + + [showingchattools] .chat-object-view { + left: 0px; + right: var(--thread-view-width-with-tools); + + border-bottom: none; + border-right: 1px solid var(--border); + } + + [showingchattools] .thread-view { + display: flex; + + left: unset; + right: 0px; + + height: unset; + width: var(--thread-view-width-with-tools); + } +} diff --git a/src/translations.ts b/src/translations.ts index 416fea28..4f56982d 100644 --- a/src/translations.ts +++ b/src/translations.ts @@ -1,9 +1,11 @@ const englishTranslations = { // general set: "Set", + save: "Save", back: "Back", undoChanges: "Undo changes", close: "Close", + discard: "Discard", // overview overview: "Overview", @@ -22,7 +24,7 @@ const englishTranslations = { serverAddressPlaceholder: "wss://192.168.0.69:3000", connectToServer: "Connect", - disconnect: "Disonnect", + disconnect: "Disconnect", mailbox: "Mailbox", requestMailbox: "Enable", @@ -34,7 +36,7 @@ const englishTranslations = { primaryChannelPlaceholder: "my-channel", addChat: "Add", - // messages + // chat showChatOptions: "show chat options", configureChatTitle: "Configure Chat", @@ -47,8 +49,9 @@ const englishTranslations = { encryptionKeyPlaceholder: "n10d2482dg283hg", showKey: "Show key", - removeChat: "Remove chat", + clearObjects: "Delete all objects", clearChatMessages: "Clear chat messages", + removeChat: "Remove chat", messageInOutbox: "Pending", @@ -57,10 +60,32 @@ const englishTranslations = { composerPlaceholder: "Type a message...", sendMessage: "Send", + // messages resendMessage: "Resend message", decryptMessage: "Decrypt message", copyMessage: "Copy message", deleteMessage: "Delete message", + + // objects + showObjects: "show objects", + createObject: "create object", + untitledObject: "Untitled Object", + + viewAll: "All", + viewNotes: "Notes", + + noObjects: "No objects", + noNotes: "No notes", + + objectTitle: "Object title", + objectTitlePlaceholder: "My object", + + objectVersion: "Object version", + noteContent: "Note", + noteContentPlaceholder: "Take a note...", + + resendObjects: "Resend all objects", + deleteObject: "Delete object", }; const allTranslations: { [key: string]: typeof englishTranslations } = { @@ -68,14 +93,16 @@ const allTranslations: { [key: string]: typeof englishTranslations } = { es: { // general set: "Guardar", + save: "Guardar", back: "Atrás", undoChanges: "Deshacer", close: "Cerrar", + discard: "Descartar", // overview overview: "Resumen", - connection: "Conexion", + connection: "Conexión", chats: "Chats", yourName: "Tu nombre", @@ -85,7 +112,7 @@ const allTranslations: { [key: string]: typeof englishTranslations } = { encryptionUnavailableMessage: "Obtén esta aplicación a traves de HTTPS o continúa sin cifrado", - serverAddress: "Direccion del servidor", + serverAddress: "Dirección del servidor", serverAddressPlaceholder: "wss://192.168.0.69:3000", connectToServer: "Conectar", @@ -101,40 +128,65 @@ const allTranslations: { [key: string]: typeof englishTranslations } = { primaryChannelPlaceholder: "mi-canal", addChat: "Añadir", - // messages + // chat 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", + secondaryChannel: "Canal secundario", + secondaryChannelPlaceholder: "Añadir canal secundario", + addSecondaryChannel: "Añadir canal secundario", + removeSecondaryChannel: "Eliminar canal secundario", encryptionKey: "Clave de cifrado", encryptionKeyPlaceholder: "n10d2482dg283hg", showKey: "Mostrar clave", + clearObjects: "Eliminar todos los objetos", + clearChatMessages: "Eliminar todos los mensajes", removeChat: "Eliminar chat", - clearChatMessages: "Eliminar todos mensajes", - - noChatSelected: "Selecciona un chat", messageInOutbox: "Pendiente", + noChatSelected: "Selecciona un chat", + composerPlaceholder: "Escribe un mensaje...", sendMessage: "Enviar", - resendMessage: "Enviar de nuevo", + // messages + resendMessage: "Reenviar mensaje", decryptMessage: "Descifrar mensaje", copyMessage: "Copiar mensaje", deleteMessage: "Eliminar este mensaje", + + // objects + showObjects: "mostrar objetos", + createObject: "añadir nuevo objeto", + untitledObject: "Sin Título", + + viewAll: "Todos", + viewNotes: "Notas", + + noObjects: "Sin objetos", + noNotes: "Sin notas", + + objectTitle: "Título", + objectTitlePlaceholder: "Mi objeto", + + objectVersion: "Version del objeto", + noteContent: "Nota", + noteContentPlaceholder: "Toma nota...", + + resendObjects: "Reenviar todos objetos", + deleteObject: "Eliminar objeto", }, de: { // general set: "OK", + save: "Sichern", back: "Zurück", undoChanges: "Änderungen verwerfen", close: "Schließen", + discard: "Verwerfen", // overview overview: "Übersicht", @@ -165,7 +217,7 @@ const allTranslations: { [key: string]: typeof englishTranslations } = { primaryChannelPlaceholder: "mein-kanal", addChat: "Hinzufügen", - // messages + // chat showChatOptions: "Chatoptionen einblenden", configureChatTitle: "Chat konfigurieren", @@ -178,20 +230,43 @@ const allTranslations: { [key: string]: typeof englishTranslations } = { encryptionKeyPlaceholder: "n10d2482dg283hg", showKey: "Schlüssel anzeigen", - removeChat: "Chat löschen", + clearObjects: "Alle Objekte löschen", clearChatMessages: "Nachrichtenverlauf leeren", - - noChatSelected: "Kein Chat ausgewählt", + removeChat: "Chat löschen", messageInOutbox: "Ausstehend", + noChatSelected: "Kein Chat ausgewählt", + composerPlaceholder: "Neue Nachricht...", sendMessage: "Senden", + // messages resendMessage: "Erneut senden", decryptMessage: "Nachricht entschlüsseln", copyMessage: "Nachricht kopieren", deleteMessage: "Nachricht löschen", + + // objects + showObjects: "Objekte anzeigen", + createObject: "Neues Objekt erstellen", + untitledObject: "Unbenannt", + + viewAll: "Alle", + viewNotes: "Notizen", + + noObjects: "Keine Objekte", + noNotes: "Keine Notizen", + + objectTitle: "Titel", + objectTitlePlaceholder: "Mein Objekt", + + objectVersion: "Version des Objekts", + noteContent: "Notiz", + noteContentPlaceholder: "Notiz eingeben...", + + resendObjects: "Objekte erneut senden", + deleteObject: "Objekt löschen", }, }; diff --git a/src/utility.ts b/src/utility.ts index a9587c1a..a769fd66 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -19,10 +19,18 @@ export const storageKeys = { return id + "messages"; }, + objects(id: string): string { + return id + "items"; + }, + outbox(id: string): string { return id + "outbox"; }, + itemOutbox(id: string): string { + return id + "item-outbox"; + }, + composingMessage(id: string): string { return id + "composing-message"; },