diff --git a/dist/index.css b/dist/index.css index 9689aecc..83ef00ce 100644 --- a/dist/index.css +++ b/dist/index.css @@ -422,24 +422,44 @@ body[showing-chat] #chat-page-wrapper { width: 100%; max-width: var(--max-content-width); } -.message-bubble > div { +.message-bubble .main { display: flex; - flex-direction: column; + flex-direction: row; background-color: var(--surface); - padding: var(--control-padding); width: 100%; max-width: var(--max-input-width); + overflow: hidden; } -.message-bubble .timestamp, -.message-bubble .sender-name { +.message-bubble .main .text-container { + display: flex; + flex-direction: column; + overflow: hidden; + width: 100%; + flex: 1; +} +.message-bubble .main .button-container { + display: flex; + align-items: start; +} +.message-bubble .main button { + justify-content: center; + padding: 0px; + width: var(--control-height); + --surface: transparent; +} +.message-bubble .main .text-container { + padding: var(--control-padding); +} +.message-bubble .main .timestamp, +.message-bubble .main .sender-name { font-size: 0.8rem; opacity: 0.6; } -.message-bubble .sender-name { +.message-bubble .main .sender-name { font-weight: bold; } -.message-bubble .body { - word-break: break-all; +.message-bubble .main .body { + word-break: break-word; } .message-bubble[sentbyuser] { justify-content: flex-end; @@ -447,7 +467,10 @@ body[showing-chat] #chat-page-wrapper { .message-bubble[sentbyuser] { align-items: flex-end; } -.message-bubble[sentbyuser] span { +.message-bubble[sentbyuser] .main { + flex-direction: row-reverse; +} +.message-bubble[sentbyuser] .main span { text-align: right; } diff --git a/dist/index.js b/dist/index.js index 0221fdb7..be0c42bb 100644 --- a/dist/index.js +++ b/dist/index.js @@ -125,6 +125,49 @@ 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).forEach((handler) => handler(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) { + if (!this.removalHandlers.has(item)) + this.removalHandlers.set(item, /* @__PURE__ */ new Set()); + this.removalHandlers.get(item).add(handler); + } + // stringification + toString() { + const array = [...this.value.entries()]; + const json = JSON.stringify(array); + return json; + } + }; function createProxyState(statesToSubscibe, fn) { const proxyState = new State(fn()); statesToSubscibe.forEach( @@ -223,6 +266,7 @@ } } } + break; } default: element.setAttribute(attributename, value); @@ -604,7 +648,6 @@ const chatMessage = _ChatModel.parseMessage(body); if (chatMessage == null) return; await this.decryptMessage(chatMessage); - this.addMessage(chatMessage); }; decryptMessage = async (chatMessage) => { const decryptedBody = await decryptString( @@ -612,6 +655,7 @@ this.info.encryptionKey ); chatMessage.body = decryptedBody; + this.addMessage(chatMessage); }; setMessageHandler = (handler) => { this.chatMessageHandler = handler; @@ -744,20 +788,37 @@ // src/Model/chatMessageViewModel.ts var ChatMessageViewModel = class { - chatModel; + chatViewModel; // data + chatMessage; channel; sender; dateSent; body = new State(""); sentByUser; - constructor(chatModel, chatMessage, sentByUser) { - this.chatModel = chatModel; - this.channel = chatMessage.channel; - this.sender = chatMessage.sender; - this.dateSent = new Date(chatMessage.dateSent).toLocaleString(); - this.body.value = chatMessage.body; + // methods + copyMessage = () => { + navigator.clipboard.writeText(this.body.value); + }; + resendMessage = () => { + this.chatViewModel.sendMessageFromBody(this.body.value); + }; + decryptMessage = () => { + this.chatViewModel.decryptMessage(this); + }; + // load + loadData = () => { + this.channel = this.chatMessage.channel; + this.sender = this.chatMessage.sender; + this.dateSent = new Date(this.chatMessage.dateSent).toLocaleString(); + this.body.value = this.chatMessage.body; + }; + // init + constructor(chatViewModel, chatMessage, sentByUser) { + this.chatViewModel = chatViewModel; + this.chatMessage = chatMessage; this.sentByUser = sentByUser; + this.loadData(); } }; @@ -783,7 +844,7 @@ selectedPage = new State( "messages" /* Messages */ ); - chatMessageViewModels = new ListState(); + chatMessageViewModels = new MapState(); composingMessage = new State(""); // guards cannotSetPrimaryChannel = createProxyState( @@ -810,11 +871,16 @@ // add addChatMessage = (chatMessage) => { const chatMessageModel = new ChatMessageViewModel( - this.chatModel, + this, chatMessage, chatMessage.sender == this.settingsViewModel.username.value ); - this.chatMessageViewModels.add(chatMessageModel); + const existingChatMessageViewModel = this.chatMessageViewModels.value.get(chatMessage.id); + if (existingChatMessageViewModel != void 0) { + existingChatMessageViewModel.body.value = chatMessage.body; + } else { + this.chatMessageViewModels.set(chatMessage.id, chatMessageModel); + } }; // settings setPrimaryChannel = () => { @@ -852,9 +918,16 @@ }; // messaging sendMessage = () => { - this.chatModel.sendMessage(this.composingMessage.value); + this.sendMessageFromBody(this.composingMessage.value); this.composingMessage.value = ""; }; + sendMessageFromBody = (body) => { + this.chatModel.sendMessage(body); + }; + decryptMessage = async (messageViewModel) => { + await this.chatModel.decryptMessage(messageViewModel.chatMessage); + messageViewModel.loadData(); + }; // restore restorePageSelection = () => { const path = storageKeys.chatLastUsedPage(this.chatModel.id); @@ -991,31 +1064,10 @@ ); } - // src/View/Components/chatMessage.tsx - function ChatMessage(chatMessageViewModel) { - return /* @__PURE__ */ createElement( - "div", - { - class: "message-bubble", - "toggle:sentbyuser": chatMessageViewModel.sentByUser - }, - /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", { class: "sender-name ellipsis" }, chatMessageViewModel.sender), /* @__PURE__ */ createElement( - "span", - { - class: "body", - "subscribe:innerText": chatMessageViewModel.body - } - ), /* @__PURE__ */ createElement("span", { class: "timestamp ellipsis" }, chatMessageViewModel.dateSent)) - ); - } - var ChatMessageViewModelToView = (chatMessageViewModel) => { - return ChatMessage(chatMessageViewModel); - }; - // src/View/translations.ts var englishTranslations = { general: { - closeButtonAudioLabel: "Close", + closeButton: "Close", deleteItemButtonAudioLabel: "delete item", setButton: "Set" }, @@ -1089,7 +1141,17 @@ }, message: { composerInputPlaceholder: "Type a message...", - sendMessageButtonAudioLabel: "send message" + sendMessageButtonAudioLabel: "send message", + showMessageInfoButtonAudioLabel: "show message info", + messageInfoHeadline: "Message Info", + sentBy: "Sent by", + timeSent: "Time sent", + channel: "Channel", + messageContent: "Message content", + copyMessageButton: "Copy message", + resendMessageButton: "Resend message", + decryptMessageButton: "Decrypt message", + deleteMessageButton: "Delete message" } } }; @@ -1099,6 +1161,61 @@ var language = navigator.language.substring(0, 2); var translations = allTranslations[language] || allTranslations.en; + // src/View/Components/chatMessageInfoModal.tsx + function ChatMessageInfoModal(chatMessageViewModel, isOpen) { + function closeModal() { + isOpen.value = false; + } + return /* @__PURE__ */ createElement("div", { class: "modal", "toggle:open": isOpen }, /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("main", null, /* @__PURE__ */ createElement("h2", null, translations.chatPage.message.messageInfoHeadline), /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, /* @__PURE__ */ createElement("div", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "account_circle"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translations.chatPage.message.sentBy), /* @__PURE__ */ createElement("b", { class: "break-word" }, chatMessageViewModel.sender))), /* @__PURE__ */ createElement("div", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "schedule"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translations.chatPage.message.timeSent), /* @__PURE__ */ createElement("b", { class: "break-word" }, chatMessageViewModel.dateSent))), /* @__PURE__ */ createElement("div", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "forum"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translations.chatPage.message.channel), /* @__PURE__ */ createElement("b", { class: "break-word" }, chatMessageViewModel.channel))), /* @__PURE__ */ createElement("div", { class: "tile" }, /* @__PURE__ */ createElement("span", { class: "icon" }, "description"), /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", null, translations.chatPage.message.messageContent), /* @__PURE__ */ createElement( + "b", + { + class: "break-word", + "subscribe:innerText": chatMessageViewModel.body + } + )))), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.copyMessage }, translations.chatPage.message.copyMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "content_copy")), /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.resendMessage }, translations.chatPage.message.resendMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "redo")), /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.decryptMessage }, translations.chatPage.message.decryptMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "key")))), /* @__PURE__ */ createElement( + "button", + { + "on:click": closeModal, + "aria-label": translations.general.closeButton + }, + translations.general.closeButton, + /* @__PURE__ */ createElement("span", { class: "icon" }, "close") + ))); + } + + // src/View/Components/chatMessage.tsx + function ChatMessage(chatMessageViewModel) { + const isInfoModalOpen = new State(false); + function openModal() { + isInfoModalOpen.value = true; + } + return /* @__PURE__ */ createElement( + "div", + { + class: "message-bubble", + "toggle:sentbyuser": chatMessageViewModel.sentByUser + }, + /* @__PURE__ */ createElement("div", { class: "main" }, /* @__PURE__ */ createElement("div", { class: "text-container" }, /* @__PURE__ */ createElement("span", { class: "sender-name ellipsis" }, chatMessageViewModel.sender), /* @__PURE__ */ createElement( + "span", + { + class: "body", + "subscribe:innerText": chatMessageViewModel.body + } + ), /* @__PURE__ */ createElement("span", { class: "timestamp ellipsis" }, chatMessageViewModel.dateSent)), /* @__PURE__ */ createElement("div", { class: "button-container" }, /* @__PURE__ */ createElement( + "button", + { + "on:click": openModal, + "aria-label": translations.chatPage.message.showMessageInfoButtonAudioLabel + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "info") + ))), + ChatMessageInfoModal(chatMessageViewModel, isInfoModalOpen) + ); + } + var ChatMessageViewModelToView = (chatMessageViewModel) => { + return ChatMessage(chatMessageViewModel); + }; + // src/View/ChatPages/messagePage.tsx function MessagePage(chatViewModel) { const messageContainer = /* @__PURE__ */ createElement( @@ -1369,7 +1486,7 @@ previousAddressConverter ] } - )), /* @__PURE__ */ createElement("button", { "on:click": connectionViewModel2.hideConnectionModal }, translations.general.closeButtonAudioLabel, /* @__PURE__ */ createElement("span", { class: "icon" }, "close"))) + )), /* @__PURE__ */ createElement("button", { "on:click": connectionViewModel2.hideConnectionModal }, translations.general.closeButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "close"))) ); } diff --git a/src/Model/chatMessageViewModel.ts b/src/Model/chatMessageViewModel.ts index 15f0e256..90057af2 100644 --- a/src/Model/chatMessageViewModel.ts +++ b/src/Model/chatMessageViewModel.ts @@ -4,27 +4,48 @@ import * as React from "bloatless-react"; import { ChatMessage, ChatModel } from "./chatModel"; +import ChatViewModel from "../ViewModel/chatViewModel"; + export default class ChatMessageViewModel { - chatModel: ChatModel; + chatViewModel: ChatViewModel; // data + chatMessage: ChatMessage; channel: string; sender: string; dateSent: string; body: React.State = new React.State(""); sentByUser: boolean; + // methods + copyMessage = (): void => { + navigator.clipboard.writeText(this.body.value); + }; + resendMessage = (): void => { + this.chatViewModel.sendMessageFromBody(this.body.value); + }; + decryptMessage = (): void => { + this.chatViewModel.decryptMessage(this); + }; + + // load + loadData = (): void => { + this.channel = this.chatMessage.channel; + this.sender = this.chatMessage.sender; + this.dateSent = new Date(this.chatMessage.dateSent).toLocaleString(); + this.body.value = this.chatMessage.body; + }; + + // init constructor( - chatModel: ChatModel, + chatViewModel: ChatViewModel, chatMessage: ChatMessage, sentByUser: boolean ) { - this.chatModel = chatModel; + this.chatViewModel = chatViewModel; - this.channel = chatMessage.channel; - this.sender = chatMessage.sender; - this.dateSent = new Date(chatMessage.dateSent).toLocaleString(); - this.body.value = chatMessage.body; + this.chatMessage = chatMessage; this.sentByUser = sentByUser; + this.loadData(); } } diff --git a/src/Model/chatModel.ts b/src/Model/chatModel.ts index f810ee3e..2a162ca0 100644 --- a/src/Model/chatModel.ts +++ b/src/Model/chatModel.ts @@ -196,7 +196,6 @@ export class ChatModel { if (chatMessage == null) return; await this.decryptMessage(chatMessage); - this.addMessage(chatMessage); }; decryptMessage = async (chatMessage: ChatMessage): Promise => { @@ -205,6 +204,7 @@ export class ChatModel { this.info.encryptionKey ); chatMessage.body = decryptedBody; + this.addMessage(chatMessage); }; setMessageHandler = (handler: (chatMessage: ChatMessage) => void): void => { diff --git a/src/View/ChatPages/messagePage.css b/src/View/ChatPages/messagePage.css index 02afc374..5545132c 100644 --- a/src/View/ChatPages/messagePage.css +++ b/src/View/ChatPages/messagePage.css @@ -30,7 +30,6 @@ } @media (max-width: 700px) { - #message-page #composer div { width: 100%; max-width: unset; @@ -79,29 +78,57 @@ max-width: var(--max-content-width); } -.message-bubble > div { +.message-bubble .main { display: flex; - flex-direction: column; + flex-direction: row; background-color: var(--surface); - padding: var(--control-padding); width: 100%; max-width: var(--max-input-width); + overflow: hidden; } -.message-bubble .timestamp, -.message-bubble .sender-name { +.message-bubble .main .text-container { + display: flex; + flex-direction: column; + + overflow: hidden; + + width: 100%; + flex: 1; +} + +.message-bubble .main .button-container { + display: flex; + align-items: start; +} + +.message-bubble .main button { + justify-content: center; + + padding: 0px; + width: var(--control-height); + + --surface: transparent; +} + +.message-bubble .main .text-container { + padding: var(--control-padding); +} + +.message-bubble .main .timestamp, +.message-bubble .main .sender-name { font-size: 0.8rem; opacity: 0.6; } -.message-bubble .sender-name { +.message-bubble .main .sender-name { font-weight: bold; } -.message-bubble .body { - word-break: break-all; +.message-bubble .main .body { + word-break: break-word; } .message-bubble[sentbyuser] { @@ -112,6 +139,10 @@ align-items: flex-end; } -.message-bubble[sentbyuser] span { +.message-bubble[sentbyuser] .main { + flex-direction: row-reverse; +} + +.message-bubble[sentbyuser] .main span { text-align: right; } diff --git a/src/View/Components/chatMessage.tsx b/src/View/Components/chatMessage.tsx index 1a8578a1..9d954b77 100644 --- a/src/View/Components/chatMessage.tsx +++ b/src/View/Components/chatMessage.tsx @@ -1,21 +1,46 @@ import * as React from "bloatless-react"; +import { ChatMessageInfoModal } from "./chatMessageInfoModal"; import ChatMessageViewModel from "../../Model/chatMessageViewModel"; +import { translations } from "../translations"; export function ChatMessage(chatMessageViewModel: ChatMessageViewModel) { + const isInfoModalOpen = new React.State(false); + function openModal() { + isInfoModalOpen.value = true; + } + return (
-
- {chatMessageViewModel.sender} - - {chatMessageViewModel.dateSent} +
+
+ + {chatMessageViewModel.sender} + + + + {chatMessageViewModel.dateSent} + +
+
+ +
+ + {ChatMessageInfoModal(chatMessageViewModel, isInfoModalOpen)}
); } diff --git a/src/View/Components/chatMessageInfoModal.tsx b/src/View/Components/chatMessageInfoModal.tsx new file mode 100644 index 00000000..0cce0ed7 --- /dev/null +++ b/src/View/Components/chatMessageInfoModal.tsx @@ -0,0 +1,84 @@ +import * as React from "bloatless-react"; + +import ChatMessageViewModel from "../../Model/chatMessageViewModel"; +import { translations } from "../translations"; + +export function ChatMessageInfoModal( + chatMessageViewModel: ChatMessageViewModel, + isOpen: React.State +) { + function closeModal() { + isOpen.value = false; + } + + return ( + + ); +} diff --git a/src/View/Modals/connectionModal.tsx b/src/View/Modals/connectionModal.tsx index 9a9dee6e..8817ab9c 100644 --- a/src/View/Modals/connectionModal.tsx +++ b/src/View/Modals/connectionModal.tsx @@ -53,7 +53,7 @@ export function ConnectionModal(connectionViewModel: ConnectionViewModel) { >
diff --git a/src/View/translations.ts b/src/View/translations.ts index a0ab5cf8..8234d94c 100644 --- a/src/View/translations.ts +++ b/src/View/translations.ts @@ -1,6 +1,6 @@ const englishTranslations = { general: { - closeButtonAudioLabel: "Close", + closeButton: "Close", deleteItemButtonAudioLabel: "delete item", setButton: "Set", @@ -102,6 +102,19 @@ const englishTranslations = { message: { composerInputPlaceholder: "Type a message...", sendMessageButtonAudioLabel: "send message", + + showMessageInfoButtonAudioLabel: "show message info", + messageInfoHeadline: "Message Info", + + sentBy: "Sent by", + timeSent: "Time sent", + channel: "Channel", + messageContent: "Message content", + + copyMessageButton: "Copy message", + resendMessageButton: "Resend message", + decryptMessageButton: "Decrypt message", + deleteMessageButton: "Delete message", } }, }; diff --git a/src/ViewModel/chatViewModel.ts b/src/ViewModel/chatViewModel.ts index a01adcac..3d4940f1 100644 --- a/src/ViewModel/chatViewModel.ts +++ b/src/ViewModel/chatViewModel.ts @@ -6,7 +6,6 @@ import StorageModel, { storageKeys } from "../Model/storageModel"; import ChatListViewModel from "./chatListViewModel"; import ChatMessageViewModel from "../Model/chatMessageViewModel"; import { Color } from "./colors"; -import SettingsModel from "../Model/settingsModel"; import SettingsViewModel from "./settingsViewModel"; import { localeCompare } from "../Model/Utility/utility"; @@ -38,8 +37,8 @@ export default class ChatViewModel { ChatPageType.Messages ); - chatMessageViewModels: React.ListState = - new React.ListState(); + chatMessageViewModels: React.MapState = + new React.MapState(); composingMessage: React.State = new React.State(""); // guards @@ -73,11 +72,18 @@ export default class ChatViewModel { // add addChatMessage = (chatMessage: ChatMessage): void => { const chatMessageModel = new ChatMessageViewModel( - this.chatModel, + this, chatMessage, chatMessage.sender == this.settingsViewModel.username.value ); - this.chatMessageViewModels.add(chatMessageModel); + + const existingChatMessageViewModel: ChatMessageViewModel | undefined = + this.chatMessageViewModels.value.get(chatMessage.id); + if (existingChatMessageViewModel != undefined) { + existingChatMessageViewModel.body.value = chatMessage.body; + } else { + this.chatMessageViewModels.set(chatMessage.id, chatMessageModel); + } }; // settings @@ -125,10 +131,21 @@ export default class ChatViewModel { // messaging sendMessage = (): void => { - this.chatModel.sendMessage(this.composingMessage.value); + this.sendMessageFromBody(this.composingMessage.value); this.composingMessage.value = ""; }; + sendMessageFromBody = (body: string): void => { + this.chatModel.sendMessage(body); + }; + + decryptMessage = async ( + messageViewModel: ChatMessageViewModel + ): Promise => { + await this.chatModel.decryptMessage(messageViewModel.chatMessage); + messageViewModel.loadData(); + }; + // restore restorePageSelection = (): void => { const path: string[] = storageKeys.chatLastUsedPage(this.chatModel.id);