diff --git a/services/static-webserver/client/Manifest.json b/services/static-webserver/client/Manifest.json index 64089c078666..743a52c5c2fb 100644 --- a/services/static-webserver/client/Manifest.json +++ b/services/static-webserver/client/Manifest.json @@ -35,6 +35,7 @@ "css": [ "jsontreeviewer/jsonTree.css", "hint/hint.css", + "marked/markdown.css", "common/common.css" ] }, diff --git a/services/static-webserver/client/source/class/osparc/conversation/AddMessage.js b/services/static-webserver/client/source/class/osparc/conversation/AddMessage.js index 0d37b8ae6e31..a3b6e7a2b6cd 100644 --- a/services/static-webserver/client/source/class/osparc/conversation/AddMessage.js +++ b/services/static-webserver/client/source/class/osparc/conversation/AddMessage.js @@ -83,17 +83,12 @@ qx.Class.define("osparc.conversation.AddMessage", { } case "comment-field": control = new osparc.editor.MarkdownEditor(); - control.addListener("keydown", e => { - if (e.isCtrlPressed() && e.getKeyIdentifier() === "Enter") { - this.addComment(); - e.stopPropagation(); - e.preventDefault(); - } - }, this); - control.getChildControl("buttons").exclude(); + control.addListener("textChanged", () => this.__addCommentPressed(), this); + control.setCompact(true); control.getChildControl("text-area").getContentElement().setStyles({ "border-top-right-radius": "0px", // no roundness there to match the arrow button }); + // make it more compact this.getChildControl("add-comment-layout").add(control, { flex: 1 }); @@ -102,7 +97,8 @@ qx.Class.define("osparc.conversation.AddMessage", { control = new qx.ui.form.Button(null, "@FontAwesome5Solid/arrow-up/16").set({ backgroundColor: "input_background", allowGrowX: false, - alignX: "right" + alignX: "right", + alignY: "middle", }); control.getContentElement().setStyles({ "border-bottom": "1px solid " + qx.theme.manager.Color.getInstance().resolve("default-button-active"), @@ -161,9 +157,6 @@ qx.Class.define("osparc.conversation.AddMessage", { // edit mode const commentField = this.getChildControl("comment-field"); commentField.setText(message["content"]); - - const addMessageButton = this.getChildControl("add-comment-button"); - addMessageButton.setLabel(this.tr("Edit message")); } }, diff --git a/services/static-webserver/client/source/class/osparc/conversation/MessageUI.js b/services/static-webserver/client/source/class/osparc/conversation/MessageUI.js index fc3d5ea21c96..be5c1129e1d3 100644 --- a/services/static-webserver/client/source/class/osparc/conversation/MessageUI.js +++ b/services/static-webserver/client/source/class/osparc/conversation/MessageUI.js @@ -28,9 +28,7 @@ qx.Class.define("osparc.conversation.MessageUI", { this.__studyData = studyData; - const layout = new qx.ui.layout.Grid(12, 2); - layout.setColumnFlex(1, 1); // content - this._setLayout(layout); + this._setLayout(new qx.ui.layout.HBox(10)); this.setPadding(5); this.set({ @@ -69,22 +67,22 @@ qx.Class.define("osparc.conversation.MessageUI", { case "thumbnail": control = new osparc.ui.basic.UserThumbnail(32).set({ marginTop: 4, + alignY: "top", }); - this._add(control, { - row: 0, - column: isMyMessage ? 2 : 0, - rowSpan: 2, - }); + this._addAt(control, isMyMessage ? 1 : 0); + break; + case "main-layout": + control = new qx.ui.container.Composite(new qx.ui.layout.VBox(2).set({ + alignX: isMyMessage ? "right" : "left" + })); + this._addAt(control, isMyMessage ? 0 : 1, { flex: 1}); break; case "header-layout": control = new qx.ui.container.Composite(new qx.ui.layout.HBox(5).set({ alignX: isMyMessage ? "right" : "left" })); control.addAt(new qx.ui.basic.Label("-"), 1); - this._add(control, { - row: 0, - column: 1 - }); + this.getChildControl("main-layout").addAt(control, 0); break; case "user-name": control = new qx.ui.basic.Label().set({ @@ -100,25 +98,21 @@ qx.Class.define("osparc.conversation.MessageUI", { }); this.getChildControl("header-layout").addAt(control, isMyMessage ? 0 : 2); break; - case "message-content": - control = new osparc.ui.markdown.Markdown().set({ - noMargin: true, - allowGrowX: true, - }); - control.getContentElement().setStyles({ - "text-align": isMyMessage ? "right" : "left", - }); - this._add(control, { - row: 1, - column: 1, + case "message-bubble": + control = new qx.ui.container.Composite(new qx.ui.layout.VBox().set({ + alignX: isMyMessage ? "right" : "left" + })).set({ + decorator: "chat-bubble", + allowGrowX: false, + padding: 8, }); + const bubbleStyle = isMyMessage ? { "border-top-right-radius": "0px" } : { "border-top-left-radius": "0px" }; + control.getContentElement().setStyles(bubbleStyle); + this.getChildControl("main-layout").addAt(control, 1); break; - case "spacer": - control = new qx.ui.core.Spacer(); - this._add(control, { - row: 1, - column: isMyMessage ? 0 : 2, - }); + case "message-content": + control = new osparc.ui.markdown.MarkdownChat(); + this.getChildControl("message-bubble").add(control); break; case "menu-button": { const buttonSize = 22; @@ -129,14 +123,10 @@ qx.Class.define("osparc.conversation.MessageUI", { allowGrowY: false, marginTop: 4, alignY: "top", - icon: "@FontAwesome5Solid/ellipsis-v/14", + icon: "@FontAwesome5Solid/ellipsis-v/12", focusable: false }); - this._add(control, { - row: 0, - column: 3, - rowSpan: 2, - }); + this._addAt(control, 2); break; } } @@ -145,13 +135,6 @@ qx.Class.define("osparc.conversation.MessageUI", { }, __applyMessage: function(message) { - const isMyMessage = this.self().isMyMessage(message); - this._getLayout().setColumnFlex(isMyMessage ? 0 : 2, 3); // spacer - - const thumbnail = this.getChildControl("thumbnail"); - - const userName = this.getChildControl("user-name"); - const createdDateData = new Date(message["created"]); const createdDate = osparc.utils.Utils.formatDateAndTime(createdDateData); const lastUpdate = this.getChildControl("last-updated"); @@ -166,6 +149,8 @@ qx.Class.define("osparc.conversation.MessageUI", { const messageContent = this.getChildControl("message-content"); messageContent.setValue(message["content"]); + const thumbnail = this.getChildControl("thumbnail"); + const userName = this.getChildControl("user-name"); if (message["userGroupId"] === "system") { userName.setValue("Support"); } else { @@ -180,8 +165,6 @@ qx.Class.define("osparc.conversation.MessageUI", { }); } - this.getChildControl("spacer"); - if (this.self().isMyMessage(message)) { const menuButton = this.getChildControl("menu-button"); diff --git a/services/static-webserver/client/source/class/osparc/data/model/Conversation.js b/services/static-webserver/client/source/class/osparc/data/model/Conversation.js index a28bd4d4b699..19d0372cc943 100644 --- a/services/static-webserver/client/source/class/osparc/data/model/Conversation.js +++ b/services/static-webserver/client/source/class/osparc/data/model/Conversation.js @@ -293,6 +293,13 @@ qx.Class.define("osparc.data.model.Conversation", { return null; }, + getFogbugzLink: function() { + if (this.getExtraContext() && "fogbugz_case_url" in this.getExtraContext()) { + return this.getExtraContext()["fogbugz_case_url"]; + } + return null; + }, + getAppointment: function() { if (this.getExtraContext() && "appointment" in this.getExtraContext()) { return this.getExtraContext()["appointment"]; diff --git a/services/static-webserver/client/source/class/osparc/editor/MarkdownEditor.js b/services/static-webserver/client/source/class/osparc/editor/MarkdownEditor.js index 79c8e94f5e51..8b12fa66a815 100644 --- a/services/static-webserver/client/source/class/osparc/editor/MarkdownEditor.js +++ b/services/static-webserver/client/source/class/osparc/editor/MarkdownEditor.js @@ -42,6 +42,14 @@ qx.Class.define("osparc.editor.MarkdownEditor", { }); }, + properties: { + compact: { + check: "Boolean", + init: null, + apply: "__applyCompact", + } + }, + members: { _createChildControlImpl: function(id) { let control; @@ -69,6 +77,12 @@ qx.Class.define("osparc.editor.MarkdownEditor", { } } return control || this.base(arguments, id); - } + }, + + __applyCompact: function(value) { + this.getChildControl("buttons").setVisibility(value ? "excluded" : "visible"); + this.getChildControl("tabs").getChildControl("bar").setVisibility(value ? "excluded" : "visible"); + this.getChildControl("subtitle").setVisibility(value ? "excluded" : "visible"); + }, } }); diff --git a/services/static-webserver/client/source/class/osparc/editor/TextEditor.js b/services/static-webserver/client/source/class/osparc/editor/TextEditor.js index 1acf44f50b52..277ea3786b42 100644 --- a/services/static-webserver/client/source/class/osparc/editor/TextEditor.js +++ b/services/static-webserver/client/source/class/osparc/editor/TextEditor.js @@ -32,6 +32,15 @@ qx.Class.define("osparc.editor.TextEditor", { } this.__addButtons(); + + this.addListener("keydown", e => { + if (e.isCtrlPressed() && e.getKeyIdentifier() === "Enter") { + const text = this.getChildControl("text-area").getValue(); + this.fireDataEvent("textChanged", text); + e.stopPropagation(); + e.preventDefault(); + } + }, this); }, events: { diff --git a/services/static-webserver/client/source/class/osparc/study/Conversation.js b/services/static-webserver/client/source/class/osparc/study/Conversation.js index 3907e5930a86..a7f3c37eaf69 100644 --- a/services/static-webserver/client/source/class/osparc/study/Conversation.js +++ b/services/static-webserver/client/source/class/osparc/study/Conversation.js @@ -342,6 +342,9 @@ qx.Class.define("osparc.study.Conversation", { switch (message["type"]) { case "MESSAGE": control = new osparc.conversation.MessageUI(message, this.__studyData); + control.getChildControl("message-content").set({ + measurerMaxWidth: 400, + }); control.addListener("messageUpdated", e => this.updateMessage(e.getData())); control.addListener("messageDeleted", e => this.deleteMessage(e.getData())); break; diff --git a/services/static-webserver/client/source/class/osparc/support/Conversation.js b/services/static-webserver/client/source/class/osparc/support/Conversation.js index 93031b0063f3..81fb93a6c79f 100644 --- a/services/static-webserver/client/source/class/osparc/support/Conversation.js +++ b/services/static-webserver/client/source/class/osparc/support/Conversation.js @@ -105,9 +105,6 @@ qx.Class.define("osparc.support.Conversation", { this.bind("conversation", control, "conversationId", { converter: conversation => conversation ? conversation.getConversationId() : null }); - // make it more compact - control.getChildControl("comment-field").getChildControl("tabs").getChildControl("bar").exclude(); - control.getChildControl("comment-field").getChildControl("subtitle").exclude(); this._addAt(control, 4); break; case "share-project-layout": @@ -155,6 +152,7 @@ qx.Class.define("osparc.support.Conversation", { // make these checks first, setConversation will reload messages if ( this.__messages.length === 1 && + this.__messages[0]["systemMessageType"] && this.__messages[0]["systemMessageType"] === osparc.support.Conversation.SYSTEM_MESSAGE_TYPE.BOOK_A_CALL ) { isBookACall = true; diff --git a/services/static-webserver/client/source/class/osparc/support/ConversationPage.js b/services/static-webserver/client/source/class/osparc/support/ConversationPage.js index c6382edfa733..429c3702a505 100644 --- a/services/static-webserver/client/source/class/osparc/support/ConversationPage.js +++ b/services/static-webserver/client/source/class/osparc/support/ConversationPage.js @@ -196,7 +196,6 @@ qx.Class.define("osparc.support.ConversationPage", { return new qx.ui.basic.Label(text).set({ font: "text-12", textColor: "text-disabled", - rich: true, allowGrowX: true, selectable: true, }); @@ -207,31 +206,24 @@ qx.Class.define("osparc.support.ConversationPage", { if (extraContext && Object.keys(extraContext).length) { const ticketIdLabel = createExtraContextLabel(`Ticket ID: ${osparc.utils.Utils.uuidToShort(conversation.getConversationId())}`); extraContextLayout.add(ticketIdLabel); - const contextProjectId = conversation.getContextProjectId(); - if (contextProjectId && amISupporter) { - const projectIdLabel = createExtraContextLabel(`Project ID: ${osparc.utils.Utils.uuidToShort(contextProjectId)}`); - extraContextLayout.add(projectIdLabel); - } - /* - const appointment = conversation.getAppointment(); - if (appointment) { - const appointmentLabel = createExtraContextLabel(); - let appointmentText = "Appointment: "; - if (appointment === "requested") { - // still pending - appointmentText += appointment; - } else { - // already set - appointmentText += osparc.utils.Utils.formatDateAndTime(new Date(appointment)); - appointmentLabel.set({ - cursor: "pointer", - toolTipText: osparc.utils.Utils.formatDateWithCityAndTZ(new Date(appointment)), + if (amISupporter) { + const fogbugzLink = conversation.getFogbugzLink(); + if (fogbugzLink) { + const text = "Fogbugz Case: " + fogbugzLink.split("/").pop(); + const fogbugzLabel = new osparc.ui.basic.LinkLabel(text, fogbugzLink).set({ + font: "link-label-12", + textColor: "text-disabled", + allowGrowX: true, }); + extraContextLayout.add(fogbugzLabel); + } + const contextProjectId = conversation.getContextProjectId(); + if (contextProjectId) { + const projectIdLabel = createExtraContextLabel(`Project ID: ${osparc.utils.Utils.uuidToShort(contextProjectId)}`); + extraContextLayout.add(projectIdLabel); } - appointmentLabel.setValue(appointmentText); - extraContextLayout.add(appointmentLabel); + } - */ } }; updateExtraContext(); diff --git a/services/static-webserver/client/source/class/osparc/theme/Decoration.js b/services/static-webserver/client/source/class/osparc/theme/Decoration.js index 0e295ac3c168..5d0fee783ee1 100644 --- a/services/static-webserver/client/source/class/osparc/theme/Decoration.js +++ b/services/static-webserver/client/source/class/osparc/theme/Decoration.js @@ -29,8 +29,9 @@ qx.Theme.define("osparc.theme.Decoration", { "chat-bubble": { style: { radius: 4, - width: 1, - color: "text-disabled" + // width: 1, + // color: "text-disabled", + backgroundColor: "background-main-2", } }, diff --git a/services/static-webserver/client/source/class/osparc/ui/markdown/Markdown.js b/services/static-webserver/client/source/class/osparc/ui/markdown/Markdown.js index c7b944ee3f06..765f6287c47e 100644 --- a/services/static-webserver/client/source/class/osparc/ui/markdown/Markdown.js +++ b/services/static-webserver/client/source/class/osparc/ui/markdown/Markdown.js @@ -92,7 +92,7 @@ qx.Class.define("osparc.ui.markdown.Markdown", { if (linkRepresentation.type === "text") { linkHtml += linkRepresentation.text; } else if (linkRepresentation.type === "image") { - linkHtml += `${linkRepresentation.text}`; + linkHtml += `${linkRepresentation.text}`; } } linkHtml += ``; diff --git a/services/static-webserver/client/source/class/osparc/ui/markdown/MarkdownChat.js b/services/static-webserver/client/source/class/osparc/ui/markdown/MarkdownChat.js new file mode 100644 index 000000000000..a27463a05d35 --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/ui/markdown/MarkdownChat.js @@ -0,0 +1,208 @@ +/* + * oSPARC - The SIMCORE frontend - https://osparc.io + * Copyright: 2025 IT'IS Foundation - https://itis.swiss + * License: MIT - https://opensource.org/licenses/MIT + * Authors: Odei Maiz (odeimaiz) + */ + +/** + * @asset(marked/marked.min.js) + * @asset(marked/markdown.css) + * @ignore(marked) + */ + +/* global marked */ + +qx.Class.define("osparc.ui.markdown.MarkdownChat", { + extend: qx.ui.embed.Html, + + /** + * @param {String} markdown Plain text accepting markdown syntax + */ + construct: function(markdown) { + this.base(arguments); + + this.set({ + allowGrowX: false, + allowGrowY: true, + overflowX: "hidden", // hide scrollbars + overflowY: "hidden", // hide scrollbars + }); + + const markdownCssUri = qx.util.ResourceManager.getInstance().toUri("marked/markdown.css"); + qx.module.Css.includeStylesheet(markdownCssUri); + + this.__loadMarked = new Promise((resolve, reject) => { + if (typeof marked === "function") { + resolve(marked); + } else { + const loader = new qx.util.DynamicScriptLoader([ + "marked/marked.min.js" + ]); + loader.addListenerOnce("ready", () => resolve(marked), this); + loader.addListenerOnce("failed", e => + reject(Error(`Failed to load ${e.getData()}`)) + ); + loader.start(); + } + }); + + if (markdown) { + this.setValue(markdown); + } + + this.addListenerOnce("appear", () => { + this.getContentElement().addClass("osparc-markdown"); + this.__scheduleResize(); // first paint sizing + }); + }, + + properties: { + /** + * Holds the raw markdown text and updates the label's {@link #value} whenever new markdown arrives. + */ + value: { + check: "String", + apply: "__applyMarkdown" + }, + + measurerMaxWidth: { + check: "Integer", + init: 220, + nullable: true, + }, + }, + + events: { + "resized": "qx.event.type.Event", + }, + + statics: { + MD_ROOT: "osparc-md-root", + MD_MEASURE: "osparc-md-measure", + }, + + members: { + __loadMarked: null, + + /** + * Apply function for the markdown property. Compiles the markdown text to HTML and applies it to the value property of the label. + * @param {String} value Plain text accepting markdown syntax. + */ + __applyMarkdown: function(value = "") { + this.__loadMarked.then(() => { + const renderer = { + link(link) { + const linkColor = qx.theme.manager.Color.getInstance().resolve("link"); + let linkHtml = `` + if (link.tokens && link.tokens.length) { + const linkRepresentation = link.tokens[0]; + if (linkRepresentation.type === "text") { + linkHtml += linkRepresentation.text; + } else if (linkRepresentation.type === "image") { + linkHtml += `${linkRepresentation.text}`; + } + } + linkHtml += ``; + return linkHtml; + } + }; + marked.use({ renderer }); + // By default, Markdown requires two spaces at the end of a line or a blank line between paragraphs to produce a line break. + // With this, a single line break (Enter) in your Markdown input will render as a
in HTML. + marked.setOptions({ breaks: true }); + + const html = marked.parse(value); + + const safeHtml = osparc.wrapper.DOMPurify.getInstance().sanitize(html); + + // flow-root prevents margin collapsing; inline style avoids extra stylesheet juggling + const max = this.getMeasurerMaxWidth() || 220; + const mdRoot = ` +
+
+ ${safeHtml} +
+
+ `; + this.setHtml(mdRoot); + + // resize once DOM is updated/painted + this.__scheduleResize(); + + // also resize once images load (they change height later) + const el = this.__getDomElement(); + if (el) { + el.querySelectorAll("img").forEach(img => { + if (!img.complete) { + img.addEventListener("load", () => this.__scheduleResize(), { once: true }); + img.addEventListener("error", () => this.__scheduleResize(), { once: true }); + } + }); + } + + // safety net; sometimes we miss an image load or so + setTimeout(() => this.__scheduleResize(), 500); + }).catch(error => console.error(error)); + }, + + __getDomElement: function() { + if (!this.getContentElement || this.getContentElement() === null) { + return null; + } + const domElement = this.getContentElement().getDomElement(); + if (domElement) { + return domElement; + } + return null; + }, + + __scheduleResize: function() { + const dom = this.__getDomElement(); + if (!dom) { + return; + } + + // collapse first so we don't re-measure an old minHeight + this.setHeight(null); + this.setMinHeight(0); + this.setWidth(null); + this.setMinWidth(0); + + window.requestAnimationFrame(() => { + // force reflow + void dom.offsetHeight; + + // measure the wrapper we injected (covers ALL children) + const root = dom.querySelector("."+this.self().MD_ROOT) || dom; + const meas = root.querySelector("."+this.self().MD_MEASURE) || root; + + const rect = meas.getBoundingClientRect(); + const rH = Math.ceil(rect.height || 0); + const rW = Math.ceil(rect.width || 0); + + // include widget insets (decorator/padding/border) + const insets = this.getInsets ? this.getInsets() : { top:0, right:0, bottom:0, left:0 }; + const totalH = Math.ceil((rH || 0) + (insets.top || 0) + (insets.bottom || 0)); + const totalW = Math.ceil((rW || 0) + (insets.left || 0) + (insets.right || 0)); + + this.setMinHeight(totalH); + this.setHeight(totalH); + + // width: shrink-to-fit, but cap at a max + this.setMaxWidth(null); // measurer already capped; we set exact width + this.setMinWidth(1); // avoid 0 when empty + this.setWidth(totalW); + + this.fireEvent("resized"); + }); + }, + } +}); diff --git a/services/static-webserver/client/source/class/osparc/utils/Utils.js b/services/static-webserver/client/source/class/osparc/utils/Utils.js index b3c9730d9e49..70a3bd1f87bc 100644 --- a/services/static-webserver/client/source/class/osparc/utils/Utils.js +++ b/services/static-webserver/client/source/class/osparc/utils/Utils.js @@ -778,8 +778,8 @@ qx.Class.define("osparc.utils.Utils", { (c ^ window.crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); }, - uuidToShort: function() { - return this.uuidV4().split("-")[0]; + uuidToShort: function(uuid) { + return uuid.split("-")[0]; }, isInZ43: function() { diff --git a/services/static-webserver/client/source/resource/marked/markdown.css b/services/static-webserver/client/source/resource/marked/markdown.css new file mode 100644 index 000000000000..9e80b2910dae --- /dev/null +++ b/services/static-webserver/client/source/resource/marked/markdown.css @@ -0,0 +1,66 @@ +.osparc-markdown { + line-height: 1.35; + word-break: break-word; +} + +/* paragraphs */ +.osparc-markdown p { + margin: 0; +} +.osparc-markdown p + p { + margin-top: .35em; +} + +/* lists */ +.osparc-markdown ul, +.osparc-markdown ol { + margin: .25em 0; + padding-left: 1.25em; +} +.osparc-markdown li { + margin: 0; + padding: 0; +} + +/* blockquotes */ +.osparc-markdown blockquote { + margin: .25em 0; + padding-left: 0.6em; +} + +/* code blocks */ +.osparc-markdown pre { + margin: .25em 0; + padding: .4em .6em; + border-radius: 4px; + white-space: pre-wrap; + word-break: break-word; +} +.osparc-markdown code { + font-family: monospace; + font-size: 0.95em; + padding: 0 .25em; + border-radius: 4px; +} + +/* headings */ +.osparc-markdown h1, +.osparc-markdown h2, +.osparc-markdown h3, +.osparc-markdown h4, +.osparc-markdown h5, +.osparc-markdown h6 { + margin: .4em 0 .25em; + line-height: 1.2; + font-weight: 600; + font-size: 1em; /* normalize size inside chat */ +} + +/* images */ +.osparc-markdown img { + max-width: 100%; + height: auto; + display: block; /* avoids weird inline metrics */ + margin: .25em 0; /* matches your spacing scale */ + border-radius: 4px; /* optional: match bubble style */ +}