diff --git a/dist/index.css b/dist/index.css index c26285aa..45517966 100644 --- a/dist/index.css +++ b/dist/index.css @@ -590,7 +590,11 @@ body[showing-chat] #chat-page-wrapper { padding: 0px; overflow: hidden; display: grid; - grid-template-rows: 1fr var(--control-height) var(--safe-area-bottom); + --composer-height: var(--control-height); + grid-template-rows: 1fr calc(var(--composer-height) + var(--gap-between-tiles)) var(--safe-area-bottom); +} +#chat-page #main #message-page .content:has(.reply-preview-wrapper .reply-preview) { + --composer-height: calc( 2 * var(--control-height) + var(--gap-between-tiles) ); } #message-page #message-container { overflow: auto; @@ -607,6 +611,32 @@ body[showing-chat] #chat-page-wrapper { border-top: 1px solid var(--border); border-left: 1px solid var(--border); } +#message-page #composer .content-width-constraint { + display: flex; + align-items: flex-end; + justify-content: center; + flex-direction: column; + gap: var(--gap-between-tiles); +} +#message-page #composer .reply-preview-wrapper { + display: flex; + justify-content: flex-end; + width: 100%; +} +#message-page #composer .reply-preview-wrapper .reply-preview { + width: 100%; + max-width: var(--max-input-width); + display: flex; + gap: var(--gap-between-tiles); +} +#message-page #composer .reply-preview-wrapper .reply-preview > div { + background: var(--surface); + display: flex; + align-items: center; + gap: var(--text-gap); + padding: 0px var(--content-padding); + width: calc(100% - var(--control-height) - var(--gap-between-tiles)); +} @media (max-width: 700px) { #message-page #composer div { width: 100%; @@ -720,11 +750,24 @@ body[showing-chat] #chat-page-wrapper { .message-bubble[sentbyuser] .tile.main span { text-align: right; } +.message-bubble > .inline-reply { + cursor: pointer; + display: flex; + align-items: center; + background: var(--surface-alt); + height: var(--control-height); + max-width: var(--max-input-width); + width: 100%; + overflow: hidden; + padding: 0px var(--control-padding); + gap: var(--text-gap); + border-left: var(--outline-width) solid var(--primary); +} .message-bubble > .message-reaction-row { max-width: var(--max-input-width); width: 100%; padding: var(--gap-between-tiles); - background: var(--surface); + background: var(--surface-alt); display: none !important; overflow: hidden; } diff --git a/dist/index.js b/dist/index.js index 1b87150d..002440ce 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1312,7 +1312,7 @@ const combinedChannel = allChannels.join("/"); return [senderName, combinedChannel]; }; - this.sendMessage = async (body, replyId, fileContent) => { + this.sendMessage = async (body, inlineReplyId, fileContent) => { const nameAndChannel = this.getNameAndChannel(); if (nameAndChannel == false) return; const [senderName, combinedChannel] = nameAndChannel; @@ -1321,6 +1321,7 @@ senderName, this.info.encryptionKey, body, + inlineReplyId, fileContent ); this.addMessage(chatMessage); @@ -1462,7 +1463,7 @@ }; } static { - this.createChatMessage = async (channel, sender, encryptionKey, body, fileContent) => { + this.createChatMessage = async (channel, sender, encryptionKey, body, inlineReplyId, fileContent) => { const chatMessage = { dataVersion: DATA_VERSION, id: v4_default(), @@ -1470,6 +1471,7 @@ sender, body, dateSent: createTimestamp(), + inlineReplyId, status: "outbox" /* Outbox */, stringifiedFile: "" }; @@ -1773,6 +1775,7 @@ /// showMessageInfoButtonAudioLabel: "show message info", messageInfoHeadline: "Message Info", + cancelReplyAudioLabel: "cancel reply", sentBy: "Sent by", timeSent: "Time sent", channel: "Channel", @@ -1780,6 +1783,7 @@ copyMessageButton: "Copy message", resendMessageButton: "Resend message", decryptMessageButton: "Decrypt message", + replyToMessageButton: "Reply to message", deleteMessageButton: "Delete message", // thumbsUpReaction: "Reaction: thumbs up", @@ -1981,6 +1985,7 @@ filterMessagesButtonAudioLabel: "nachrichten filtern", showMessageInfoButtonAudioLabel: "nachrichteninfo anzeigen", messageInfoHeadline: "Nachrichteninfo", + cancelReplyAudioLabel: "antwort abbrechen", sentBy: "Gesendet von", timeSent: "Sendezeit", channel: "Kanal", @@ -1988,6 +1993,7 @@ copyMessageButton: "Nachricht kopieren", resendMessageButton: "Nachricht erneut senden", decryptMessageButton: "Nachricht entschl\xFCsseln", + replyToMessageButton: "Auf Nachricht antworten", deleteMessageButton: "Nachricht l\xF6schen", // thumbsUpReaction: "Reaktion: Daumen hoch", @@ -2179,6 +2185,7 @@ filterMessagesButtonAudioLabel: "filtrar mensajes", showMessageInfoButtonAudioLabel: "mostrar informaci\xF3n del mensaje", messageInfoHeadline: "Informaci\xF3n del Mensaje", + cancelReplyAudioLabel: "abortar la respuesta", sentBy: "Enviado por", timeSent: "Hora de env\xEDo", channel: "Canal", @@ -2186,6 +2193,7 @@ copyMessageButton: "Copiar mensaje", resendMessageButton: "Reenviar mensaje", decryptMessageButton: "Desencriptar mensaje", + replyToMessageButton: "Responder al mensaje", deleteMessageButton: "Eliminar mensaje", // thumbsUpReaction: "Reacci\xF3n: pulgar hacia arriba", @@ -2768,6 +2776,7 @@ this.coreViewModel = coreViewModel2; this.messagePageViewModel = messagePageViewModel; this.body = new State(""); + this.inlineReply = void 0; this.status = new State( void 0 ); @@ -2820,6 +2829,14 @@ this.decryptMessage = () => { this.messagePageViewModel.decryptMessage(this); }; + this.reply = () => { + this.messagePageViewModel.replyingMessage.value = this; + this.hideInfoModal(); + }; + this.cancelReply = () => { + if (this.messagePageViewModel.replyingMessage.value != this) return; + this.messagePageViewModel.replyingMessage.value = void 0; + }; // view this.showInfoModal = () => { this.isPresentingInfoModal.value = true; @@ -2852,6 +2869,9 @@ this.dateSent = new Date(this.chatMessage.dateSent).toLocaleString(); this.body.value = this.chatMessage.body; this.status.value = this.chatMessage.status; + if (this.chatMessage.inlineReplyId) { + this.inlineReply = this.messagePageViewModel.chatMessageViewModels.value.get(this.chatMessage.inlineReplyId); + } }; this.chatMessage = chatMessage; this.sentByUser = sentByUser; @@ -2958,7 +2978,9 @@ this.filteredMessageViewModels = new ListState(); this.isFilterModalOpen = new State(false); this.reactionFilter = new State(void 0); - this.replyingMessageId = new State(void 0); + this.replyingMessage = new State( + void 0 + ); this.composingMessage = new State(""); // methods this.sendMessage = () => { @@ -2967,8 +2989,15 @@ this.composingMessage.value = ""; }; this.sendMessageFromBody = (body) => { - this.chatViewModel.chatModel.sendMessage(body, this.replyingMessageId.value); - this.replyingMessageId.value = void 0; + let replyId = void 0; + if (this.replyingMessage.value) { + replyId = this.replyingMessage.value.chatMessage.id; + } + this.chatViewModel.chatModel.sendMessage( + body, + replyId + ); + this.replyingMessage.value = void 0; }; this.decryptMessage = async (messageViewModel) => { const chatMessage = messageViewModel.chatMessage; @@ -3383,9 +3412,18 @@ this.registerKeyStroke("," /* Settings */, this.showSettings); this.registerKeyStroke("enter" /* Apply */, this.hideSettings); this.registerKeyStroke(";" /* Create */, this.createTask); - this.registerKeyStroke("j", () => this.selectedPage.value = "list" /* List */); - this.registerKeyStroke("k", () => this.selectedPage.value = "kanban" /* Kanban */); - this.registerKeyStroke("l", () => this.selectedPage.value = "status-grid" /* StatusGrid */); + this.registerKeyStroke( + "j", + () => this.selectedPage.value = "list" /* List */ + ); + this.registerKeyStroke( + "k", + () => this.selectedPage.value = "kanban" /* Kanban */ + ); + this.registerKeyStroke( + "l", + () => this.selectedPage.value = "status-grid" /* StatusGrid */ + ); this.taskPageViewModel.registerContext(this.boardInfo.fileId, this); } }; @@ -4435,7 +4473,7 @@ "description", coreViewModel2.translations.chatPage.message.messageContent, chatMessageViewModel.body - )), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.copyMessage }, coreViewModel2.translations.chatPage.message.copyMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "content_copy")), /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.resendMessage }, coreViewModel2.translations.chatPage.message.resendMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "redo")), /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.decryptMessage }, coreViewModel2.translations.chatPage.message.decryptMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "key"))), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, MessageReactionButtonRow( + )), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.copyMessage }, coreViewModel2.translations.chatPage.message.copyMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "content_copy")), /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.resendMessage }, coreViewModel2.translations.chatPage.message.resendMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "redo")), /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.decryptMessage }, coreViewModel2.translations.chatPage.message.decryptMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "key")), /* @__PURE__ */ createElement("button", { "on:click": chatMessageViewModel.reply }, coreViewModel2.translations.chatPage.message.replyToMessageButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "reply"))), /* @__PURE__ */ createElement("hr", null), /* @__PURE__ */ createElement("div", { class: "flex-column gap" }, MessageReactionButtonRow( coreViewModel2, chatMessageViewModel ), /* @__PURE__ */ createElement( @@ -4451,9 +4489,70 @@ ); } + // src/View/viewController.ts + var ItemTracker = class { + }; + var ViewTrackerMap = class { + constructor() { + this.trackers = /* @__PURE__ */ new Map(); + this.register = (key) => { + if (this.trackers.has(key)) { + return this.trackers.get(key); + } + const tracker = new ItemTracker(); + this.trackers.set(key, tracker); + return tracker; + }; + this.setItems = (key, views) => { + const tracker = this.register(key); + if (tracker.items != void 0) { + return tracker.items; + } else { + tracker.items = views(); + return tracker.items; + } + }; + } + }; + var ViewController = class { + static { + this.chatPages = new ItemTracker(); + } + static { + this.taskPages = new ViewTrackerMap(); + } + static { + this.boardPages = new ViewTrackerMap(); + } + static { + this.inlineReplies = new ViewTrackerMap(); + } + static { + this.messageIcons = new ViewTrackerMap(); + } + static scrollToView(id) { + const element = document.getElementById(id); + if (!element) return; + element.scrollIntoView(); + element.setAttribute("scrolled", ""); + setTimeout(() => element.removeAttribute("scrolled"), 1e3); + } + }; + + // src/View/Components/inlineReply.tsx + function InlineReply(chatMessageViewModel) { + const reply = chatMessageViewModel.inlineReply; + if (reply == void 0) return /* @__PURE__ */ createElement("div", null); + const scroll = () => { + ViewController.scrollToView(reply.chatMessage.id); + }; + return /* @__PURE__ */ createElement("div", { class: "inline-reply", "on:click": scroll }, /* @__PURE__ */ createElement("span", null, reply.sender), /* @__PURE__ */ createElement("b", { class: "ellipsis", "subscribe:innerText": reply.body })); + } + // src/View/Components/chatMessage.tsx function ChatMessage4(coreViewModel2, chatMessageViewModel) { - const statusIcon = createProxyState( + const persistenceId = chatMessageViewModel.chatMessage.id; + const statusIcon = ViewController.messageIcons.setItems(persistenceId, () => createProxyState( [chatMessageViewModel.status], () => { switch (chatMessageViewModel.status.value) { @@ -4467,14 +4566,16 @@ return "warning"; } } - ); + )); return /* @__PURE__ */ createElement( "div", { class: "message-bubble", + id: chatMessageViewModel.chatMessage.id, "toggle:sentbyuser": chatMessageViewModel.sentByUser, "toggle:hidden": chatMessageViewModel.isHidden }, + InlineReply(chatMessageViewModel), /* @__PURE__ */ createElement("div", { class: "main tile" }, /* @__PURE__ */ createElement("div", { class: "text-container" }, /* @__PURE__ */ createElement("span", { class: "sender-name ellipsis" }, chatMessageViewModel.sender), /* @__PURE__ */ createElement( "span", { @@ -4494,6 +4595,13 @@ "aria-label": coreViewModel2.translations.chatPage.message.showMessageInfoButtonAudioLabel }, /* @__PURE__ */ createElement("span", { class: "icon" }, "info") + ), /* @__PURE__ */ createElement( + "button", + { + "on:click": chatMessageViewModel.reply, + "aria-label": coreViewModel2.translations.chatPage.message.replyToMessageButton + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "reply") ))), MessageReactionButtonRow(coreViewModel2, chatMessageViewModel), ChatMessageInfoModal(coreViewModel2, chatMessageViewModel) @@ -4572,6 +4680,22 @@ )))), /* @__PURE__ */ createElement("button", { "on:click": messagePageViewModel.hideFilterModal }, coreViewModel2.translations.general.closeButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "close")))); } + // src/View/Components/replyPreview.tsx + function ReplyPreview(coreViewModel2, chatMessageViewModel) { + const scroll = () => { + chatMessageViewModel.scrollIntoView(); + }; + return /* @__PURE__ */ createElement("div", { class: "reply-preview slide-up", "on:click": scroll }, /* @__PURE__ */ createElement("div", null, /* @__PURE__ */ createElement("span", { class: "secondary" }, chatMessageViewModel.sender), /* @__PURE__ */ createElement("b", { class: "ellipsis", "subscribe:innerText": chatMessageViewModel.body })), /* @__PURE__ */ createElement( + "button", + { + class: "standard square", + "on:click": chatMessageViewModel.cancelReply, + "aria-label": coreViewModel2.translations.chatPage.message.cancelReplyAudioLabel + }, + /* @__PURE__ */ createElement("span", { class: "icon" }, "close") + )); + } + // src/View/ChatPages/messagePage.tsx function MessagePage(coreViewModel2, messagePageViewModel) { messagePageViewModel.loadData(); @@ -4588,6 +4712,21 @@ ] } ); + const persistenceId = messagePageViewModel.chatViewModel.chatModel.id; + const replyPreview = ViewController.inlineReplies.setItems( + persistenceId, + () => createProxyState( + [messagePageViewModel.replyingMessage], + () => { + if (messagePageViewModel.replyingMessage.value == void 0) + return /* @__PURE__ */ createElement("div", null); + return ReplyPreview( + coreViewModel2, + messagePageViewModel.replyingMessage.value + ); + } + ) + ); function scrollDown(hard = false) { if (hard) { messageContainer.setAttribute("scroll-hard", ""); @@ -4604,6 +4743,9 @@ scrollDownIfApplicable ); setTimeout(() => scrollDown(true), 100); + messagePageViewModel.replyingMessage.subscribeSilent(() => { + setFocusWithDelay(); + }); setFocusWithDelay(); return /* @__PURE__ */ createElement("div", { id: "message-page" }, /* @__PURE__ */ createElement("div", { class: "pane-wrapper" }, /* @__PURE__ */ createElement("div", { class: "pane" }, /* @__PURE__ */ createElement("div", { class: "toolbar" }, /* @__PURE__ */ createElement("span", { class: "title" }, coreViewModel2.translations.chatPage.message.messagesHeadline), /* @__PURE__ */ createElement("span", null, /* @__PURE__ */ createElement( "button", @@ -4614,7 +4756,13 @@ "toggle:selected": messagePageViewModel.isFilterActive }, /* @__PURE__ */ createElement("span", { class: "icon" }, "filter_alt") - ))), /* @__PURE__ */ createElement("div", { class: "content" }, messageContainer, /* @__PURE__ */ createElement("div", { id: "composer" }, /* @__PURE__ */ createElement("div", { class: "content-width-constraint" }, /* @__PURE__ */ createElement("div", { class: "input-width-constraint" }, /* @__PURE__ */ createElement( + ))), /* @__PURE__ */ createElement("div", { class: "content" }, messageContainer, /* @__PURE__ */ createElement("div", { id: "composer" }, /* @__PURE__ */ createElement("div", { class: "content-width-constraint" }, /* @__PURE__ */ createElement( + "div", + { + class: "reply-preview-wrapper", + "children:set": replyPreview + } + ), /* @__PURE__ */ createElement("div", { class: "input-width-constraint" }, /* @__PURE__ */ createElement( "input", { id: "focused", @@ -5205,48 +5353,11 @@ )), /* @__PURE__ */ createElement("button", { "on:click": close }, coreViewModel2.translations.general.closeButton, /* @__PURE__ */ createElement("span", { class: "icon" }, "close")))); } - // src/View/viewPersistence.ts - var PageTracker = class { - }; - var PageTrackerMap = class { - constructor() { - this.trackers = /* @__PURE__ */ new Map(); - this.register = (key) => { - if (this.trackers.has(key)) { - return this.trackers.get(key); - } - const tracker = new PageTracker(); - this.trackers.set(key, tracker); - return tracker; - }; - this.setPages = (key, pages) => { - const tracker = this.register(key); - if (tracker.pages != void 0) { - return tracker.pages; - } else { - tracker.pages = pages(); - return tracker.pages; - } - }; - } - }; - var ViewPersistence = class { - static { - this.chatPages = new PageTracker(); - } - static { - this.taskPages = new PageTrackerMap(); - } - static { - this.boardPages = new PageTrackerMap(); - } - }; - // src/View/ChatPages/boardPage.tsx function BoardPage(coreViewModel2, boardViewModel) { boardViewModel.loadData(); const persistenceId = boardViewModel.boardInfo.fileId; - const pages = ViewPersistence.boardPages.setPages( + const pages = ViewController.boardPages.setItems( persistenceId, () => createProxyState([boardViewModel.selectedPage], () => { switch (boardViewModel.selectedPage.value) { @@ -5389,7 +5500,7 @@ () => taskPageViewModel.selectedBoardId.value != void 0 ); const persistenceId = taskPageViewModel.chatViewModel.chatModel.id; - const pages = ViewPersistence.taskPages.setPages( + const pages = ViewController.taskPages.setItems( persistenceId, () => createProxyState([taskPageViewModel.selectedBoardId], () => { const selectedBoardId = taskPageViewModel.selectedBoardId.value; @@ -5461,7 +5572,7 @@ // src/View/chatPage.tsx function ChatPage(coreViewModel2, chatViewModel) { - ViewPersistence.chatPages.pages = createProxyState( + ViewController.chatPages.items = createProxyState( [chatViewModel.selectedPage], () => { switch (chatViewModel.selectedPage.value) { @@ -5556,7 +5667,7 @@ "div", { id: "main", - "children:set": ViewPersistence.chatPages.pages + "children:set": ViewController.chatPages.items } )) ); diff --git a/dist/styles.css b/dist/styles.css index 7914b8da..1b021830 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -49,6 +49,7 @@ body { --surface: hsla(0, 0%, 0%, 5%); --surface-hover: hsla(0, 0%, 5%, 10%); --surface-active: hsla(0, 0%, 5%, 15%); + --surface-alt: hsla(0, 0%, 0%, 2%); --border: hsla(0, 0%, 0%, 10%); --surface-border: #8d8d8d; @@ -86,9 +87,10 @@ body[theme="dark"] { --layer: hsla(0, 0%, 15%, 60%); - --surface: hsla(0, 0%, 100%, 4%); + --surface: hsla(0, 0%, 100%, 5%); --surface-hover: hsla(0, 0%, 100%, 10%); --surface-active: hsla(0, 0%, 100%, 15%); + --surface-alt: hsla(0, 0%, 100%, 2%); --border: hsla(0, 0%, 100%, 10%); --surface-border: #6f6f6f; @@ -166,8 +168,11 @@ select:not(.tile *), background: var(--surface); } -@media (hover: hover) { +.surface-alt { + background: var(--surface-alt); +} +@media (hover: hover) { button:hover, select:hover, button.tile.ghost:hover { @@ -259,8 +264,9 @@ SIZES body { /* gap */ --gap-between-tiles: 4px; + --gap-in-controls: 7px; --gap-in-tab-links: 1rem; - --gap-in-controls: 1.2rem; + --text-gap: 1.2rem; --large-gap: 2rem; /* outline */ @@ -822,6 +828,12 @@ select+.icon { height: var(--control-height); } +button.square { + width: var(--control-height); + padding: 0px; + justify-content: center; +} + header { height: var(--header-height); } @@ -1227,6 +1239,19 @@ STATE } } +[scrolled] { + animation: 1s ease scroll-highlight; +} + +@keyframes scroll-highlight { + 0%, 40%, 50%, 90% { + opacity: 1; + } + 10%, 30%, 60%, 80% { + opacity: .5; + } +} + /* ICONS */ diff --git a/src/Model/Chat/chatModel.ts b/src/Model/Chat/chatModel.ts index 627b9048..d5d11dc1 100644 --- a/src/Model/Chat/chatModel.ts +++ b/src/Model/Chat/chatModel.ts @@ -180,7 +180,7 @@ export default class ChatModel { sendMessage = async ( body: string, - replyId?: string, + inlineReplyId?: string, fileContent?: FileContent, ): Promise => { const nameAndChannel = this.getNameAndChannel(); @@ -192,6 +192,7 @@ export default class ChatModel { senderName, this.info.encryptionKey, body, + inlineReplyId, fileContent, ); @@ -373,6 +374,7 @@ export default class ChatModel { sender: string, encryptionKey: string, body: string, + inlineReplyId?: string, fileContent?: FileContent, ): Promise => { const chatMessage: ChatMessage = { @@ -384,6 +386,7 @@ export default class ChatModel { sender, body, dateSent: createTimestamp(), + inlineReplyId, status: ChatMessageStatuses.Outbox, stringifiedFile: "", diff --git a/src/View/ChatPages/boardPage.tsx b/src/View/ChatPages/boardPage.tsx index d6e5b3c5..1fc50326 100644 --- a/src/View/ChatPages/boardPage.tsx +++ b/src/View/ChatPages/boardPage.tsx @@ -13,7 +13,7 @@ import { TaskSettingsModal } from "../Modals/taskSettingsModal"; import { TaskViewModelToEntry } from "../Components/taskEntry"; import { setFocusWithDelay } from "../utility"; import CoreViewModel from "../../ViewModel/Global/coreViewModel"; -import { ViewPersistence } from "../viewPersistence"; +import { ViewController } from "../viewController"; export function BoardPage( coreViewModel: CoreViewModel, @@ -23,7 +23,7 @@ export function BoardPage( // main content const persistenceId = boardViewModel.boardInfo.fileId; - const pages = ViewPersistence.boardPages.setPages(persistenceId, () => + const pages = ViewController.boardPages.setItems(persistenceId, () => React.createProxyState([boardViewModel.selectedPage], () => { switch (boardViewModel.selectedPage.value) { case BoardPageTypes.Kanban: { diff --git a/src/View/ChatPages/messagePage.css b/src/View/ChatPages/messagePage.css index a95d631b..7bff3b17 100644 --- a/src/View/ChatPages/messagePage.css +++ b/src/View/ChatPages/messagePage.css @@ -1,11 +1,21 @@ /* layout */ #chat-page #main #message-page .content { padding: 0px; - overflow: hidden; - display: grid; - grid-template-rows: 1fr var(--control-height) var(--safe-area-bottom); + --composer-height: var(--control-height); + grid-template-rows: + 1fr calc(var(--composer-height) + var(--gap-between-tiles)) + var(--safe-area-bottom); +} + +#chat-page + #main + #message-page + .content:has(.reply-preview-wrapper .reply-preview) { + --composer-height: calc( + 2 * var(--control-height) + var(--gap-between-tiles) + ); } #message-page #message-container { @@ -20,21 +30,51 @@ #message-page #composer input { margin: 0px; - width: 100%; max-width: unset; - flex: 1; - border-top: 1px solid var(--border); border-left: 1px solid var(--border); } +#message-page #composer .content-width-constraint { + display: flex; + align-items: flex-end; + justify-content: center; + flex-direction: column; + gap: var(--gap-between-tiles); +} + +#message-page #composer .reply-preview-wrapper { + display: flex; + justify-content: flex-end; + width: 100%; +} + +#message-page #composer .reply-preview-wrapper .reply-preview { + width: 100%; + max-width: var(--max-input-width); + + display: flex; + gap: var(--gap-between-tiles); +} + +#message-page #composer .reply-preview-wrapper .reply-preview > div { + background: var(--surface); + + display: flex; + align-items: center; + gap: var(--text-gap); + + padding: 0px var(--content-padding); + + width: calc(100% - var(--control-height) - var(--gap-between-tiles)); +} + @media (max-width: 700px) { #message-page #composer div { width: 100%; max-width: unset; - display: flex; } } @@ -44,7 +84,6 @@ #message-page #composer .input-width-constraint { display: flex; justify-content: flex-end; - width: 100%; max-width: var(--max-content-width); } @@ -178,12 +217,31 @@ text-align: right; } +.message-bubble > .inline-reply { + cursor: pointer; + + display: flex; + align-items: center; + + background: var(--surface-alt); + height: var(--control-height); + + max-width: var(--max-input-width); + width: 100%; + overflow: hidden; + + padding: 0px var(--control-padding); + gap: var(--text-gap); + + border-left: var(--outline-width) solid var(--primary); +} + .message-bubble > .message-reaction-row { max-width: var(--max-input-width); width: 100%; padding: var(--gap-between-tiles); - background: var(--surface); + background: var(--surface-alt); display: none !important; overflow: hidden; diff --git a/src/View/ChatPages/messagePage.tsx b/src/View/ChatPages/messagePage.tsx index 492570bd..ac0cdeeb 100644 --- a/src/View/ChatPages/messagePage.tsx +++ b/src/View/ChatPages/messagePage.tsx @@ -8,6 +8,8 @@ import ChatMessageViewModel from "../../ViewModel/Chat/chatMessageViewModel"; import { ChatMessage } from "../Components/chatMessage"; import { MessageFilterModal } from "../Modals/messageFilterModal"; import { setFocusWithDelay } from "../utility"; +import { ViewController } from "../viewController"; +import { ReplyPreview } from "../Components/replyPreview"; export function MessagePage( coreViewModel: CoreViewModel, @@ -31,6 +33,23 @@ export function MessagePage( > ); + const persistenceId = messagePageViewModel.chatViewModel.chatModel.id; + const replyPreview = ViewController.inlineReplies.setItems( + persistenceId, + () => + React.createProxyState( + [messagePageViewModel.replyingMessage], + () => { + if (messagePageViewModel.replyingMessage.value == undefined) + return
; + return ReplyPreview( + coreViewModel, + messagePageViewModel.replyingMessage.value, + ); + }, + ), + ); + function scrollDown(hard: boolean = false) { if (hard) { messageContainer.setAttribute("scroll-hard", ""); @@ -51,6 +70,9 @@ export function MessagePage( ); setTimeout(() => scrollDown(true), 100); + messagePageViewModel.replyingMessage.subscribeSilent(() => { + setFocusWithDelay(); + }) setFocusWithDelay(); return ( @@ -84,6 +106,10 @@ export function MessagePage( {messageContainer}
+
+ const pages = ViewController.taskPages.setItems(persistenceId, () => React.createProxyState([taskPageViewModel.selectedBoardId], () => { const selectedBoardId = taskPageViewModel.selectedBoardId.value; if (selectedBoardId == undefined) { diff --git a/src/View/Components/chatMessage.tsx b/src/View/Components/chatMessage.tsx index 425c975e..0be3a8c6 100644 --- a/src/View/Components/chatMessage.tsx +++ b/src/View/Components/chatMessage.tsx @@ -5,12 +5,15 @@ import { ChatMessageStatuses } from "../../Model/Chat/chatModel"; import ChatMessageViewModel from "../../ViewModel/Chat/chatMessageViewModel"; import { MessageReactionButtonRow } from "./messageReactionButtonRow"; import CoreViewModel from "../../ViewModel/Global/coreViewModel"; +import { InlineReply } from "./inlineReply"; +import { ViewController } from "../viewController"; export function ChatMessage( coreViewModel: CoreViewModel, chatMessageViewModel: ChatMessageViewModel, ) { - const statusIcon = React.createProxyState( + const persistenceId = chatMessageViewModel.chatMessage.id; + const statusIcon = ViewController.messageIcons.setItems(persistenceId,()=> React.createProxyState( [chatMessageViewModel.status], () => { switch (chatMessageViewModel.status.value) { @@ -23,15 +26,17 @@ export function ChatMessage( default: return "warning"; } - }, - ); + } + )); return (
+ {InlineReply(chatMessageViewModel)}
@@ -60,6 +65,15 @@ export function ChatMessage( > info +
{MessageReactionButtonRow(coreViewModel, chatMessageViewModel)} diff --git a/src/View/Components/inlineReply.tsx b/src/View/Components/inlineReply.tsx new file mode 100644 index 00000000..12ee1912 --- /dev/null +++ b/src/View/Components/inlineReply.tsx @@ -0,0 +1,23 @@ +import * as React from "bloatless-react"; + +import ChatMessageViewModel from "../../ViewModel/Chat/chatMessageViewModel"; +import CoreViewModel from "../../ViewModel/Global/coreViewModel"; +import { ViewController } from "../viewController"; + +export function InlineReply ( + chatMessageViewModel: ChatMessageViewModel, +) { + const reply = chatMessageViewModel.inlineReply; + if (reply == undefined) return
+ + const scroll = () => { + ViewController.scrollToView(reply.chatMessage.id); + } + + return ( +
+ {reply.sender} + +
+ ); +} diff --git a/src/View/Components/replyPreview.tsx b/src/View/Components/replyPreview.tsx new file mode 100644 index 00000000..255ff3f3 --- /dev/null +++ b/src/View/Components/replyPreview.tsx @@ -0,0 +1,28 @@ +import * as React from "bloatless-react"; + +import ChatMessageViewModel from "../../ViewModel/Chat/chatMessageViewModel"; +import CoreViewModel from "../../ViewModel/Global/coreViewModel"; + +export function ReplyPreview( + coreViewModel: CoreViewModel, + chatMessageViewModel: ChatMessageViewModel, +) { + const scroll = () => { + chatMessageViewModel.scrollIntoView(); + } + return ( +
+
+ {chatMessageViewModel.sender} + +
+ +
+ ); +} diff --git a/src/View/Modals/chatMessageInfoModal.tsx b/src/View/Modals/chatMessageInfoModal.tsx index cae73a7b..67f6e6c2 100644 --- a/src/View/Modals/chatMessageInfoModal.tsx +++ b/src/View/Modals/chatMessageInfoModal.tsx @@ -73,6 +73,13 @@ export function ChatMessageInfoModal( } key +

diff --git a/src/View/chatPage.tsx b/src/View/chatPage.tsx index 6b2c7cca..7fe21d8a 100644 --- a/src/View/chatPage.tsx +++ b/src/View/chatPage.tsx @@ -10,13 +10,13 @@ import { MessagePage } from "./ChatPages/messagePage"; import { SettingsPage } from "./ChatPages/settingsPage"; import { TaskPage } from "./ChatPages/taskPage"; import CoreViewModel from "../ViewModel/Global/coreViewModel"; -import { ViewPersistence } from "./viewPersistence"; +import { ViewController } from "./viewController"; export function ChatPage( coreViewModel: CoreViewModel, chatViewModel: ChatViewModel, ) { - ViewPersistence.chatPages.pages = React.createProxyState( + ViewController.chatPages.items = React.createProxyState( [chatViewModel.selectedPage], () => { switch (chatViewModel.selectedPage.value) { @@ -132,7 +132,7 @@ export function ChatPage(
diff --git a/src/View/translations.ts b/src/View/translations.ts index 8f949450..01750872 100644 --- a/src/View/translations.ts +++ b/src/View/translations.ts @@ -199,6 +199,7 @@ const englishTranslations = { showMessageInfoButtonAudioLabel: "show message info", messageInfoHeadline: "Message Info", + cancelReplyAudioLabel: "cancel reply", sentBy: "Sent by", timeSent: "Time sent", @@ -208,6 +209,7 @@ const englishTranslations = { copyMessageButton: "Copy message", resendMessageButton: "Resend message", decryptMessageButton: "Decrypt message", + replyToMessageButton: "Reply to message", deleteMessageButton: "Delete message", // @@ -496,6 +498,7 @@ export const allTranslations: { showMessageInfoButtonAudioLabel: "nachrichteninfo anzeigen", messageInfoHeadline: "Nachrichteninfo", + cancelReplyAudioLabel: "antwort abbrechen", sentBy: "Gesendet von", timeSent: "Sendezeit", @@ -505,6 +508,7 @@ export const allTranslations: { copyMessageButton: "Nachricht kopieren", resendMessageButton: "Nachricht erneut senden", decryptMessageButton: "Nachricht entschlüsseln", + replyToMessageButton: "Auf Nachricht antworten", deleteMessageButton: "Nachricht löschen", // @@ -772,6 +776,7 @@ export const allTranslations: { showMessageInfoButtonAudioLabel: "mostrar información del mensaje", messageInfoHeadline: "Información del Mensaje", + cancelReplyAudioLabel: "abortar la respuesta", sentBy: "Enviado por", timeSent: "Hora de envío", @@ -781,6 +786,7 @@ export const allTranslations: { copyMessageButton: "Copiar mensaje", resendMessageButton: "Reenviar mensaje", decryptMessageButton: "Desencriptar mensaje", + replyToMessageButton: "Responder al mensaje", deleteMessageButton: "Eliminar mensaje", // diff --git a/src/View/viewController.ts b/src/View/viewController.ts new file mode 100644 index 00000000..992f0c9c --- /dev/null +++ b/src/View/viewController.ts @@ -0,0 +1,48 @@ +import * as React from "bloatless-react"; + +class ItemTracker { + items: React.State | undefined; +} + +class ViewTrackerMap { + trackers = new Map>(); + + private register = (key: string): ItemTracker => { + if (this.trackers.has(key)) { + return this.trackers.get(key); + } + const tracker = new ItemTracker(); + this.trackers.set(key, tracker); + return tracker; + }; + + setItems = ( + key: string, + views: () => React.State, + ): React.State | undefined => { + const tracker: ItemTracker = this.register(key); + + if (tracker.items != undefined) { + return tracker.items; + } else { + tracker.items = views(); + return tracker.items; + } + }; +} + +export class ViewController { + static readonly chatPages = new ItemTracker(); + static readonly taskPages = new ViewTrackerMap(); + static readonly boardPages = new ViewTrackerMap(); + static readonly inlineReplies = new ViewTrackerMap(); + static readonly messageIcons = new ViewTrackerMap(); + + static scrollToView(id: string) { + const element = document.getElementById(id); + if (!element) return + element.scrollIntoView(); + element.setAttribute("scrolled", ""); + setTimeout(()=>element.removeAttribute("scrolled"), 1000); + } +} diff --git a/src/View/viewPersistence.ts b/src/View/viewPersistence.ts deleted file mode 100644 index 93a86e54..00000000 --- a/src/View/viewPersistence.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from "bloatless-react"; - -class PageTracker { - pages: React.State | undefined; -} - -class PageTrackerMap { - trackers = new Map(); - - private register = (key: string): PageTracker => { - if (this.trackers.has(key)) { - return this.trackers.get(key); - } - const tracker = new PageTracker(); - this.trackers.set(key, tracker); - return tracker; - }; - - setPages = ( - key: string, - pages: () => React.State, - ): React.State | undefined => { - const tracker: PageTracker = this.register(key); - - if (tracker.pages != undefined) { - return tracker.pages; - } else { - tracker.pages = pages(); - return tracker.pages; - } - }; -} - -export class ViewPersistence { - static readonly chatPages = new PageTracker(); - static readonly taskPages = new PageTrackerMap(); - static readonly boardPages = new PageTrackerMap(); -} diff --git a/src/ViewModel/Chat/chatMessageViewModel.ts b/src/ViewModel/Chat/chatMessageViewModel.ts index a3e14112..5a1119ee 100644 --- a/src/ViewModel/Chat/chatMessageViewModel.ts +++ b/src/ViewModel/Chat/chatMessageViewModel.ts @@ -17,6 +17,7 @@ export default class ChatMessageViewModel { sender: string; dateSent: string; body: React.State = new React.State(""); + inlineReply: ChatMessageViewModel | undefined = undefined; status: React.State = new React.State( undefined, ); @@ -80,6 +81,15 @@ export default class ChatMessageViewModel { this.messagePageViewModel.decryptMessage(this); }; + reply = (): void => { + this.messagePageViewModel.replyingMessage.value = this; + this.hideInfoModal(); + }; + cancelReply = (): void => { + if (this.messagePageViewModel.replyingMessage.value != this) return; + this.messagePageViewModel.replyingMessage.value = undefined; + }; + // view showInfoModal = (): void => { this.isPresentingInfoModal.value = true; @@ -119,6 +129,10 @@ export default class ChatMessageViewModel { this.dateSent = new Date(this.chatMessage.dateSent).toLocaleString(); this.body.value = this.chatMessage.body; this.status.value = this.chatMessage.status; + + if (this.chatMessage.inlineReplyId) { + this.inlineReply = this.messagePageViewModel.chatMessageViewModels.value.get(this.chatMessage.inlineReplyId); + } }; // init diff --git a/src/ViewModel/Pages/boardViewModel.ts b/src/ViewModel/Pages/boardViewModel.ts index bff8c069..d0f241e4 100644 --- a/src/ViewModel/Pages/boardViewModel.ts +++ b/src/ViewModel/Pages/boardViewModel.ts @@ -318,9 +318,18 @@ export default class BoardViewModel extends TaskContainingPageViewModel { this.registerKeyStroke(CommonKeys.Apply, this.hideSettings); this.registerKeyStroke(CommonKeys.Create, this.createTask); - this.registerKeyStroke("j", ()=>this.selectedPage.value = BoardPageTypes.List); - this.registerKeyStroke("k", ()=>this.selectedPage.value = BoardPageTypes.Kanban); - this.registerKeyStroke("l", ()=>this.selectedPage.value = BoardPageTypes.StatusGrid); + this.registerKeyStroke( + "j", + () => (this.selectedPage.value = BoardPageTypes.List), + ); + this.registerKeyStroke( + "k", + () => (this.selectedPage.value = BoardPageTypes.Kanban), + ); + this.registerKeyStroke( + "l", + () => (this.selectedPage.value = BoardPageTypes.StatusGrid), + ); this.taskPageViewModel.registerContext(this.boardInfo.fileId, this); } diff --git a/src/ViewModel/Pages/messagePageViewModel.ts b/src/ViewModel/Pages/messagePageViewModel.ts index f510b600..7f2731c0 100644 --- a/src/ViewModel/Pages/messagePageViewModel.ts +++ b/src/ViewModel/Pages/messagePageViewModel.ts @@ -23,7 +23,9 @@ export default class MessagePageViewModel extends Context { reactionFilter = new React.State(undefined); isFilterActive: React.State; - replyingMessageId = new React.State(undefined) + replyingMessage = new React.State( + undefined, + ); composingMessage = new React.State(""); // guards @@ -38,8 +40,15 @@ export default class MessagePageViewModel extends Context { }; sendMessageFromBody = (body: string): void => { - this.chatViewModel.chatModel.sendMessage(body, this.replyingMessageId.value); - this.replyingMessageId.value = undefined; + let replyId: string|undefined = undefined; + if (this.replyingMessage.value) { + replyId = this.replyingMessage.value.chatMessage.id; + } + this.chatViewModel.chatModel.sendMessage( + body, + replyId, + ); + this.replyingMessage.value = undefined; }; decryptMessage = async (