From 0cab76badae685a130d2e050f4150cf1f681ab99 Mon Sep 17 00:00:00 2001 From: Hannes Drittler Date: Wed, 17 Jun 2026 20:00:56 +0200 Subject: [PATCH 1/5] Change ChatDisplay component: - change signature of ChatMessage to allow a content list instead of just a text - this enables the component to embed nested components and multiple texts - change signature of ChatDisplayModel: getChatMessages instead of getPreviousMessages must be implemented - add initialTopMessageId to the component - add command scrollToMessage to the component - add event messageViewed to the component - add event photoClicked to the component - using photo thumbnail if given - remove workaround "scroll to bottom" on resize --- .../less/components/UiChatDisplay.less | 13 +- teamapps-client/ts/modules/UiChatDisplay.ts | 277 ++++++++++++++---- teamapps-ui-api/src/main/dto/UiChatView.dto | 11 + .../ux/component/chat/ChatDisplay.java | 124 +++++++- .../ux/component/chat/ChatDisplayModel.java | 73 ++++- .../ux/component/chat/ChatMessage.java | 7 + .../ux/component/chat/ChatMessageBatch.java | 6 +- .../chat/ChatMessageComponentContent.java | 25 ++ .../ux/component/chat/ChatMessageContent.java | 34 +++ .../chat/ChatMessageTextContent.java | 23 ++ .../teamapps/ux/component/chat/ChatPhoto.java | 3 + .../chat/InMemoryChatDisplayModel.java | 36 +-- .../component/chat/PhotoClickedEventData.java | 23 ++ .../ux/component/chat/SimpleChatMessage.java | 20 +- 14 files changed, 583 insertions(+), 92 deletions(-) create mode 100644 teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageComponentContent.java create mode 100644 teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageContent.java create mode 100644 teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageTextContent.java create mode 100644 teamapps-ux/src/main/java/org/teamapps/ux/component/chat/PhotoClickedEventData.java diff --git a/teamapps-client/less/components/UiChatDisplay.less b/teamapps-client/less/components/UiChatDisplay.less index b09989938..836e0abd1 100644 --- a/teamapps-client/less/components/UiChatDisplay.less +++ b/teamapps-client/less/components/UiChatDisplay.less @@ -25,7 +25,7 @@ display: grid; grid-template-columns: auto 1fr auto; grid-template-rows: auto; - grid-template-areas: "userimage userNickname" "userimage text" "userimage photos" "userimage files"; + grid-template-areas: "userimage userNickname" "userimage content" "userimage photos" "userimage files"; .user-image { grid-area: userimage; @@ -42,10 +42,15 @@ grid-area: userNickname; font-weight: bold; } - .text { - grid-area: text; + .content { + grid-area: content; overflow: hidden; text-overflow: ellipsis; + + .content-component { + min-width: 0; + margin-top: 2px; + } } .photos { grid-area: photos; @@ -91,7 +96,7 @@ //.user-nickname { // align-self: center; //} - .text, .photos, .files { + .content, .photos, .files { display: none; } diff --git a/teamapps-client/ts/modules/UiChatDisplay.ts b/teamapps-client/ts/modules/UiChatDisplay.ts index bce799999..0a8979678 100644 --- a/teamapps-client/ts/modules/UiChatDisplay.ts +++ b/teamapps-client/ts/modules/UiChatDisplay.ts @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -17,21 +17,34 @@ * limitations under the License. * =========================LICENSE_END================================== */ +import {Autolinker} from "autolinker"; +import { + UiChatDisplay_MessageViewedEvent, + UiChatDisplay_PhotoClickedEvent, + UiChatDisplayCommandHandler, + UiChatDisplayConfig, + UiChatDisplayEventSource +} from "../generated/UiChatDisplayConfig"; +import {UiChatMessageBatchConfig} from "../generated/UiChatMessageBatchConfig"; +import {UiChatMessageConfig} from "../generated/UiChatMessageConfig"; +import {UiChatMessageContentConfig} from "../generated/UiChatMessageContentConfig"; import {AbstractUiComponent} from "./AbstractUiComponent"; -import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {addDelegatedEventListener, humanReadableFileSize, parseHtml, prependChild, removeDangerousTags} from "./Common"; -import {UiChatMessageConfig} from "../generated/UiChatMessageConfig"; -import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {UiChatDisplayCommandHandler, UiChatDisplayConfig,} from "../generated/UiChatDisplayConfig"; -import {UiSpinner} from "./micro-components/UiSpinner"; -import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; -import {Autolinker} from "autolinker"; import {ContextMenu} from "./micro-components/ContextMenu"; +import {UiSpinner} from "./micro-components/UiSpinner"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiComponent} from "./UiComponent"; -import {UiChatMessageBatchConfig} from "../generated/UiChatMessageBatchConfig"; -import {debounce, debouncedMethod, DebounceMode} from "./util/debounce"; +import {debouncedMethod, DebounceMode} from "./util/debounce"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; -export class UiChatDisplay extends AbstractUiComponent implements UiChatDisplayCommandHandler { +type MessageScrollAlignment = "top" | "bottom" | "nearest"; + +export class UiChatDisplay extends AbstractUiComponent implements UiChatDisplayCommandHandler, UiChatDisplayEventSource { + + public readonly onMessageViewed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onPhotoClicked: TeamAppsEvent = new TeamAppsEvent(); private $main: HTMLElement; private gotFirstMessage: boolean = false; @@ -40,12 +53,14 @@ export class UiChatDisplay extends AbstractUiComponent impl private $loadingIndicatorWrapper: HTMLElement; private $messages: HTMLElement; private contextMenu: ContextMenu; + private lastMessageViewedEventMessageId = Number.NEGATIVE_INFINITY; + private viewedEventsEnabled = false; constructor(config: UiChatDisplayConfig, context: TeamAppsUiContext) { super(config, context); this.$main = parseHtml(`
@@ -58,10 +73,12 @@ export class UiChatDisplay extends AbstractUiComponent impl this.$loadingIndicatorWrapper.classList.remove('hidden'); this.requestPreviousMessages(); } + this.fireMessageViewedForNewestVisibleMessage(); }); if (config.initialMessages) { - this.addMessages(config.initialMessages); + this.appendMessages(config.initialMessages); + this.applyInitialScrollPosition(); } else { this.requestPreviousMessages(); } @@ -69,17 +86,23 @@ export class UiChatDisplay extends AbstractUiComponent impl this.contextMenu = new ContextMenu(); addDelegatedEventListener(this.$messages, ".message", "contextmenu", (element, ev) => { if (this._config.contextMenuEnabled) { - let chatMessageId = Number(element.getAttribute("data-id")); + const chatMessageId = Number(element.getAttribute("data-id")); this.contextMenu.open(ev, async requestId => { - let contentComponent = await config.requestContextMenu({chatMessageId}) as UiComponent; - if (contentComponent != null) { - this.contextMenu.setContent(contentComponent, requestId); + const contextMenuComponent = await config.requestContextMenu({chatMessageId}) as UiComponent; + if (contextMenuComponent != null) { + this.contextMenu.setContent(contextMenuComponent, requestId); } else { this.contextMenu.close(requestId); } }); } - }) + }); + + setTimeout(() => this.deFactoVisibilityChanged.addListener(visible => { + if (visible) { // to fix scroll position when this component gets re-attached + this.applyScrollPosition(this.lastMessageViewedEventMessageId, "bottom"); + } + }), 500); // delay to ensure the component is initially attached first } @debouncedMethod(500, DebounceMode.LATER) @@ -89,12 +112,12 @@ export class UiChatDisplay extends AbstractUiComponent impl this._config.requestPreviousMessages({}).then(batch => { const heightBefore = this.$messages.offsetHeight; batch.messages.reverse().forEach(messageConfig => { - const uiChatMessage = new UiChatMessage(messageConfig); + const uiChatMessage = new UiChatMessage(messageConfig, this.handlePhotoClicked); prependChild(this.$messages, uiChatMessage.getMainDomElement()); this.uiChatMessages.splice(0, 0, uiChatMessage); }); const heightAfter = this.$messages.offsetHeight; - this.$main.scroll({top: heightAfter - heightBefore}); + this.setScrollTop(heightAfter - heightBefore, "auto"); if (batch.containsFirstMessage) { this.gotFirstMessage = true; @@ -105,75 +128,180 @@ export class UiChatDisplay extends AbstractUiComponent impl } } - doGetMainElement(): HTMLElement { + public doGetMainElement(): HTMLElement { return this.$main; } - addMessages(batch: UiChatMessageBatchConfig): void { + public addMessages(batch: UiChatMessageBatchConfig): void { + this.appendMessages(batch); + this.scrollToBottom("smooth", true); + } + + private appendMessages(batch: UiChatMessageBatchConfig): void { batch.messages.forEach(messageConfig => { - const chatMessage = new UiChatMessage(messageConfig); + const chatMessage = new UiChatMessage(messageConfig, this.handlePhotoClicked); this.$messages.appendChild(chatMessage.getMainDomElement()); this.uiChatMessages.push(chatMessage); }); if (batch.containsFirstMessage) { this.gotFirstMessage = true; } - this.scrollToBottom(); } - updateMessage(message: UiChatMessageConfig) { + public updateMessage(message: UiChatMessageConfig) { this.uiChatMessages.find(m => m.id === message.id) ?.update(message); + this.doWhenAllImagesAreLoaded(() => this.fireMessageViewedForNewestVisibleMessage()); } - deleteMessage(messageId: number) { - let messageIndex = this.uiChatMessages.findIndex(m => m.id === messageId); + public deleteMessage(messageId: number) { + const messageIndex = this.uiChatMessages.findIndex(m => m.id === messageId); if (messageIndex >= 0) { - let uiChatMessage = this.uiChatMessages[messageIndex]; + const uiChatMessage = this.uiChatMessages[messageIndex]; uiChatMessage.getMainDomElement().remove(); this.uiChatMessages.splice(messageIndex, 1); + this.fireMessageViewedForNewestVisibleMessage(); } } - clearMessages(batch: UiChatMessageBatchConfig): void { + public clearMessages(batch: UiChatMessageBatchConfig): void { this.uiChatMessages = []; this.$messages.innerHTML = ''; this.gotFirstMessage = false; this.requestingPreviousMessages = false; - this.addMessages(batch); + this.viewedEventsEnabled = false; + this.appendMessages(batch); + this.applyInitialScrollPosition(); + } + + public scrollToMessage(messageId: number): void { + this.doWhenAllImagesAreLoaded(() => this.scrollToMessageById(messageId, "nearest", "smooth", true)); + } + + private scrollToMessageById(messageId: number, alignment: MessageScrollAlignment, behavior: ScrollBehavior, fireViewed: boolean): boolean { + const message = this.uiChatMessages.find(m => m.id === messageId); + if (message == null) { + return false; + } + this.setScrollTop(this.getScrollTopForMessage(message.getMainDomElement(), alignment), behavior); + if (fireViewed) { + this.fireMessageViewedAfterScroll(behavior); + } + return true; + } + + private getScrollTopForMessage($message: HTMLElement, alignment: MessageScrollAlignment): number { + const messageTop = $message.offsetTop; + const messageBottom = messageTop + $message.offsetHeight; + if (alignment === "top" || $message.offsetHeight > this.$main.clientHeight) { + return messageTop; + } else if (alignment === "bottom") { + return messageBottom - this.$main.clientHeight; + } + + const viewportTop = this.$main.scrollTop; + const viewportBottom = viewportTop + this.$main.clientHeight; + if (messageTop < viewportTop) { + return messageTop; + } else if (messageBottom > viewportBottom) { + return messageBottom - this.$main.clientHeight; + } + return viewportTop; } @executeWhenFirstDisplayed(true) - private scrollToBottom() { - const scrollToBottom = ()=> { - this.$main.scroll({ - top: 100000000, - behavior: 'smooth' + private applyInitialScrollPosition() { + this.applyScrollPosition(this._config.initialTopMessageId); + } + + private applyScrollPosition(topMessageId?: number, alignment: MessageScrollAlignment = "top") { + this.doWhenAllImagesAreLoaded(() => { + let initialMessageFound = false; + if (topMessageId != null) { + initialMessageFound = this.scrollToMessageById(topMessageId, alignment, "auto", false); + } + if (!initialMessageFound) { + this.scrollToBottom("auto", false); + } + requestAnimationFrame(() => { + this.viewedEventsEnabled = true; + this.fireMessageViewedForNewestVisibleMessage(); }); + }); + } + + private scrollToBottom(behavior: ScrollBehavior, fireViewed: boolean) { + this.doWhenAllImagesAreLoaded(() => { + this.setScrollTop(this.$main.scrollHeight - this.$main.clientHeight, behavior); + if (fireViewed) { + this.fireMessageViewedAfterScroll(behavior); + } + }); + } + + private setScrollTop(scrollTop: number, behavior: ScrollBehavior) { + const top = Math.max(0, scrollTop); + if (behavior === "auto") { + this.$main.scrollTop = top; + } else { + this.$main.scroll({top, behavior}); + } + } + + private fireMessageViewedAfterScroll(behavior: ScrollBehavior) { + if (behavior === "smooth") { + setTimeout(() => this.fireMessageViewedForNewestVisibleMessage(), 300); + } else { + requestAnimationFrame(() => this.fireMessageViewedForNewestVisibleMessage()); } - this.doWhenAllImagesAreLoaded(scrollToBottom); } private doWhenAllImagesAreLoaded(action: () => void) { - let allImages = Array.from(this.$messages.querySelectorAll(":scope img")); - let incompleteImages: HTMLImageElement[] = allImages + const allImages = Array.from(this.$messages.querySelectorAll(":scope img")); + const incompleteImages: HTMLImageElement[] = allImages .map(img => img as HTMLImageElement) .filter(img => !img.complete); if (incompleteImages.length > 0) { Promise.all(incompleteImages.map(img => new Promise((resolve, reject) => { - img.addEventListener("load", resolve, {once:true}); - img.addEventListener("error", resolve, {once:true}); // since we are only interested in when loading activity is done - }))).then(action) + img.addEventListener("load", resolve, {once: true}); + img.addEventListener("error", resolve, {once: true}); // since we are only interested in when loading activity is done + }))).then(action); } else { action(); } } - onResize() { - this.scrollToBottom(); // hack. actually, I want to scrollToBottom only when this component gets re-attached... + private fireMessageViewedForNewestVisibleMessage() { + if (!this.viewedEventsEnabled || this.$main.clientHeight <= 0) { + return; + } + let newestVisibleMessageId = this.lastMessageViewedEventMessageId; + for (const message of this.uiChatMessages) { + if (newestVisibleMessageId < message.id && this.isMessageInViewport(message.getMainDomElement())) { + newestVisibleMessageId = message.id; + } + } + if (newestVisibleMessageId > this.lastMessageViewedEventMessageId) { + this.lastMessageViewedEventMessageId = newestVisibleMessageId; + this.onMessageViewed.fire({messageId: newestVisibleMessageId}); + } } - closeContextMenu(): void { + private isMessageInViewport($message: HTMLElement) { + const viewportRect = this.$main.getBoundingClientRect(); + const messageRect = $message.getBoundingClientRect(); + return messageRect.bottom > viewportRect.top && messageRect.top < viewportRect.bottom; + } + + private readonly handlePhotoClicked = (messageId: number, photoIndex: number) => { + this.onPhotoClicked.fire({messageId, photoIndex}); + } + + public onResize() { + this.doWhenAllImagesAreLoaded(() => this.fireMessageViewedForNewestVisibleMessage()); + } + + public closeContextMenu(): void { this.contextMenu.close(); } } @@ -206,11 +334,14 @@ class UiChatMessage { }); private $main: HTMLElement; + private $content: HTMLElement; private $photos: HTMLElement; private $files: HTMLElement; private config: UiChatMessageConfig; + private readonly onPhotoClicked: (messageId: number, photoIndex: number) => void; - constructor(config: UiChatMessageConfig) { + constructor(config: UiChatMessageConfig, onPhotoClicked: (messageId: number, photoIndex: number) => void) { + this.onPhotoClicked = onPhotoClicked; this.$main = parseHtml(`
`); this.update(config); } @@ -219,21 +350,29 @@ class UiChatMessage { this.config = config; this.$main.classList.toggle("deleted", config.deleted); this.$main.innerHTML = ""; - let text = removeDangerousTags(this.config.text); - text = UiChatMessage.AUTOLINKER.link(text); - this.$main.appendChild(parseHtml(``)) - this.$main.appendChild(parseHtml(`
${this.config.userNickname}
`)) - this.$main.appendChild(parseHtml(`
${text}
`)) - this.$main.appendChild(parseHtml(`
`)) - this.$main.appendChild(parseHtml(`
`)) - this.$main.appendChild(parseHtml(`
`)) + this.$main.appendChild(parseHtml(``)); + this.$main.appendChild(parseHtml(`
${this.config.userNickname}
`)); + this.$main.appendChild(parseHtml(`
`)); + this.$main.appendChild(parseHtml(`
`)); + this.$main.appendChild(parseHtml(`
`)); + this.$main.appendChild(parseHtml(`
`)); + this.$content = this.$main.querySelector(":scope .content"); this.$photos = this.$main.querySelector(":scope .photos"); this.$files = this.$main.querySelector(":scope .files"); + this.getContent().forEach(content => this.renderContent(content)); if (this.config.photos != null) { - this.config.photos.forEach(photo => { - this.$photos.appendChild(parseHtml(``)) + this.config.photos.forEach((photo, photoIndex) => { + const $photo = document.createElement("img"); + $photo.classList.add("photo"); + $photo.src = photo.thumbnailUrl ?? photo.imageUrl; + $photo.title = photo.fileName ?? ""; + $photo.addEventListener("click", ev => { + ev.preventDefault(); + this.onPhotoClicked(this.config.id, photoIndex); + }); + this.$photos.appendChild($photo); }); } if (this.config.files != null) { @@ -242,11 +381,35 @@ class UiChatMessage {
${file.name}
${humanReadableFileSize(file.length)}
- `)) + `)); }); } } + private getContent(): UiChatMessageContentConfig[] { + if (this.config.content != null && this.config.content.length > 0) { + return this.config.content; + } + return this.config.text != null ? [{text: this.config.text}] : []; + } + + private renderContent(content: UiChatMessageContentConfig) { + if (content.component != null) { + const $componentWrapper = parseHtml(`
`); + $componentWrapper.appendChild((content.component as UiComponent).getMainElement()); + this.$content.appendChild($componentWrapper); + } + if (content.text != null) { + this.$content.appendChild(this.createTextElement(content.text)); + } + } + + private createTextElement(text: string) { + text = removeDangerousTags(text); + text = UiChatMessage.AUTOLINKER.link(text); + return parseHtml(`
${text}
`); + } + public get id() { return this.config.id; } diff --git a/teamapps-ui-api/src/main/dto/UiChatView.dto b/teamapps-ui-api/src/main/dto/UiChatView.dto index 4e8326c0e..4490ade88 100644 --- a/teamapps-ui-api/src/main/dto/UiChatView.dto +++ b/teamapps-ui-api/src/main/dto/UiChatView.dto @@ -19,6 +19,7 @@ */ class UiChatDisplay extends UiComponent { UiChatMessageBatch initialMessages; + Integer initialTopMessageId; boolean contextMenuEnabled = false; String deletedMessageIcon; @@ -26,10 +27,14 @@ class UiChatDisplay extends UiComponent { command updateMessage(UiChatMessage message); command deleteMessage(int messageId); command clearMessages(UiChatMessageBatch messages); + command scrollToMessage(int messageId); command closeContextMenu(); query requestPreviousMessages() returns UiChatMessageBatch; query requestContextMenu(int chatMessageId) returns UiComponent*; + + event messageViewed(int messageId); + event photoClicked(int messageId, int photoIndex); } class UiChatInput extends UiComponent { @@ -61,11 +66,17 @@ class UiChatMessage { String userImageUrl; String userNickname; String text; + List content; List photos; List files; boolean deleted; } +class UiChatMessageContent { + String text; + UiComponent* component; +} + class UiChatPhoto { String fileName; String thumbnailUrl; diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java index 768960313..10e70ae19 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,6 +22,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.teamapps.dto.*; +import org.teamapps.event.Event; import org.teamapps.icon.material.MaterialIcon; import org.teamapps.icon.material.MaterialIconStyles; import org.teamapps.icons.Icon; @@ -29,7 +30,7 @@ import org.teamapps.ux.component.Component; import java.lang.invoke.MethodHandles; -import java.util.List; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -37,13 +38,18 @@ public class ChatDisplay extends AbstractComponent { private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + public final Event onMessageViewed = new Event<>(); + public final Event onPhotoClicked = new Event<>(); + private final ChatDisplayModel model; private int messagesFetchSize = 50; + private int initialTopMessageId = -1; private int earliestKnownMessageId = Integer.MAX_VALUE; private Icon deletedMessageIcon = MaterialIcon.DELETE.withStyle(MaterialIconStyles.OUTLINE_GREY_900); private Function contextMenuProvider = null; + private final Map> renderedContentComponentsByMessageId = new HashMap<>(); public ChatDisplay(ChatDisplayModel model) { this.model = model; @@ -58,13 +64,15 @@ public ChatDisplay(ChatDisplayModel model) { }); model.onMessageDeleted().addListener((messageId) -> { if (earliestKnownMessageId <= messageId) { + unrenderContentComponents(messageId); queueCommandIfRendered(() -> new UiChatDisplay.DeleteMessageCommand(getId(), messageId)); } }); model.onAllDataChanged().addListener(aVoid -> { - ChatMessageBatch messageBatch = this.getModel().getLastChatMessages(this.messagesFetchSize); + ChatMessageBatch messageBatch = getInitialMessageBatch(); this.earliestKnownMessageId = Integer.MAX_VALUE; updateEarliestKnownMessageId(messageBatch); + unrenderAllContentComponents(); queueCommandIfRendered(() -> new UiChatDisplay.ClearMessagesCommand(getId(), createUiChatMessageBatch(messageBatch))); }); } @@ -73,14 +81,24 @@ public ChatDisplay(ChatDisplayModel model) { public UiChatDisplay createUiComponent() { UiChatDisplay uiChatDisplay = new UiChatDisplay(); mapAbstractUiComponentProperties(uiChatDisplay); - ChatMessageBatch modelResponse = model.getLastChatMessages(messagesFetchSize); + ChatMessageBatch modelResponse = getInitialMessageBatch(); + this.earliestKnownMessageId = Integer.MAX_VALUE; updateEarliestKnownMessageId(modelResponse); uiChatDisplay.setInitialMessages(createUiChatMessageBatch(modelResponse)); + uiChatDisplay.setInitialTopMessageId(initialTopMessageId); uiChatDisplay.setContextMenuEnabled(contextMenuProvider != null); uiChatDisplay.setDeletedMessageIcon(getSessionContext().resolveIcon(deletedMessageIcon)); return uiChatDisplay; } + private ChatMessageBatch getInitialMessageBatch() { + ChatMessageBatch lastMessages = model.getLastChatMessages(messagesFetchSize); + if (initialTopMessageId <= 0 || lastMessages.containsMessage(initialTopMessageId)) { + return lastMessages; + } + return model.getLastChatMessages(initialTopMessageId, 5); + } + private void updateEarliestKnownMessageId(ChatMessageBatch response) { earliestKnownMessageId = response.getEarliestMessageId() != null && response.getEarliestMessageId() < this.earliestKnownMessageId ? response.getEarliestMessageId() : earliestKnownMessageId; } @@ -106,9 +124,23 @@ public Object handleUiQuery(UiQuery query) { } } + @Override + public void handleUiEvent(UiEvent event) { + switch (event.getUiEventType()) { + case UI_CHAT_DISPLAY_MESSAGE_VIEWED: + UiChatDisplay.MessageViewedEvent messageViewedEvent = (UiChatDisplay.MessageViewedEvent) event; + onMessageViewed.fire(messageViewedEvent.getMessageId()); + break; + case UI_CHAT_DISPLAY_PHOTO_CLICKED: + UiChatDisplay.PhotoClickedEvent photoClickedEvent = (UiChatDisplay.PhotoClickedEvent) event; + onPhotoClicked.fire(new PhotoClickedEventData(photoClickedEvent.getMessageId(), photoClickedEvent.getPhotoIndex())); + break; + } + } + private List createUiChatMessages(List chatMessages) { return chatMessages.stream() - .map(message -> createUiChatMessage(message)) + .map(this::createUiChatMessage) .collect(Collectors.toList()); } @@ -123,18 +155,78 @@ private UiChatMessage createUiChatMessage(ChatMessage message) { uiChatMessage.setUserNickname(message.getUserNickname()); uiChatMessage.setUserImageUrl(message.getUserImage().getUrl(getSessionContext())); uiChatMessage.setText(message.getText()); + uiChatMessage.setContent(createUiChatMessageContent(message)); uiChatMessage.setPhotos(message.getPhotos() != null ? message.getPhotos().stream() - .map(photo -> createUiChatPhoto(photo)) + .map(this::createUiChatPhoto) .collect(Collectors.toList()) : null); uiChatMessage.setFiles(message.getFiles() != null ? message.getFiles().stream() - .map(file -> createUiChatFile(file)) + .map(this::createUiChatFile) .collect(Collectors.toList()) : null); uiChatMessage.setDeleted(message.isDeleted()); return uiChatMessage; } + private List createUiChatMessageContent(ChatMessage message) { + List content = message.getContent() == null ? List.of() : message.getContent(); + List contentComponents = content.stream() + .filter(ChatMessageComponentContent.class::isInstance) + .map(ChatMessageComponentContent.class::cast) + .map(ChatMessageComponentContent::component) + .filter(Objects::nonNull) + .toList(); + syncRenderedContentComponents(message.getId(), contentComponents); + return content.stream() + .map(contentPart -> createUiChatMessageContent(message.getId(), contentPart)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + private UiChatMessageContent createUiChatMessageContent(int messageId, ChatMessageContent contentPart) { + UiChatMessageContent uiContent = new UiChatMessageContent(); + if (contentPart instanceof ChatMessageTextContent textContent) { + uiContent.setText(textContent.text()); + return uiContent; + } else if (contentPart instanceof ChatMessageComponentContent componentContent && componentContent.component() != null) { + uiContent.setComponent(createContentComponentReference(messageId, componentContent.component())); + return uiContent; + } + return null; + } + + private void syncRenderedContentComponents(int messageId, List contentComponents) { + List previousComponents = renderedContentComponentsByMessageId.get(messageId); + if (previousComponents != null && !previousComponents.equals(contentComponents)) { + unrenderContentComponents(messageId); + } + if (contentComponents.isEmpty()) { + renderedContentComponentsByMessageId.remove(messageId); + } else { + contentComponents.forEach(component -> component.setParent(this)); + renderedContentComponentsByMessageId.put(messageId, new ArrayList<>(contentComponents)); + } + } + + private UiClientObjectReference createContentComponentReference(int messageId, Component contentComponent) { + contentComponent.setParent(this); + return contentComponent.createUiReference(); + } + + private void unrenderContentComponents(int messageId) { + List contentComponents = renderedContentComponentsByMessageId.remove(messageId); + if (contentComponents != null) { + contentComponents.stream() + .filter(Component::isRendered) + .forEach(Component::unrender); + } + } + + private void unrenderAllContentComponents() { + renderedContentComponentsByMessageId.keySet().stream().toList().forEach(this::unrenderContentComponents); + } + private UiChatPhoto createUiChatPhoto(ChatPhoto photo) { UiChatPhoto uiChatPhoto = new UiChatPhoto(); + uiChatPhoto.setFileName(photo.getFileName()); uiChatPhoto.setThumbnailUrl(photo.getThumbnail() != null ? photo.getThumbnail().getUrl(getSessionContext()) : null); uiChatPhoto.setImageUrl(photo.getImage().getUrl(getSessionContext())); return uiChatPhoto; @@ -166,6 +258,18 @@ public void setMessagesFetchSize(int messagesFetchSize) { } } + public int getInitialTopMessageId() { + return initialTopMessageId; + } + + public void setInitialTopMessageId(int initialTopMessageId) { + boolean changed = initialTopMessageId != this.initialTopMessageId; + this.initialTopMessageId = initialTopMessageId; + if (changed) { + reRenderIfRendered(); + } + } + public Function getContextMenuProvider() { return contextMenuProvider; } @@ -178,6 +282,10 @@ public void closeContextMenu() { queueCommandIfRendered(() -> new UiChatDisplay.CloseContextMenuCommand(getId())); } + public void scrollToMessage(int messageId) { + queueCommandIfRendered(() -> new UiChatDisplay.ScrollToMessageCommand(getId(), messageId)); + } + public Icon getDeletedMessageIcon() { return deletedMessageIcon; } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplayModel.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplayModel.java index d170f067f..b108ce7c2 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplayModel.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplayModel.java @@ -23,20 +23,89 @@ public interface ChatDisplayModel { + /** + * Fires when new messages were appended to the chat. + */ Event onMessagesAdded(); + /** + * Fires when a message was deleted. + */ Event onMessageDeleted(); + /** + * Fires when an already known message changed. + */ Event onMessageChanged(); + /** + * Fires when the backing data changed in a way that requires the display to reload its current initial range. + */ Event onAllDataChanged(); + /** + * Finds a single chat message by id. + * + * @param id message id + * @return the matching message, or {@code null} if no such message exists + */ ChatMessage getChatMessageById(int id); - ChatMessageBatch getPreviousMessages(Integer earliestKnownMessageId, int numberOfMessages); + /** + * Loads messages older than the earliest message currently known by the client. + *

+ * The returned batch must not include {@code earliestKnownMessageId}; otherwise paging upwards would duplicate + * the first known message. + * + * @param earliestKnownMessageId first message id currently known by the client, or {@code null} to load the newest messages + * @param numberOfMessages maximum number of older messages to load + * @return message batch in ascending chronological order + */ + default ChatMessageBatch getPreviousMessages(Integer earliestKnownMessageId, int numberOfMessages) { + return getChatMessages(earliestKnownMessageId, null, numberOfMessages); + } + /** + * Loads the newest messages of the chat. + * + * @param numberOfMessages maximum number of messages to load + * @return message batch in ascending chronological order + */ default ChatMessageBatch getLastChatMessages(int numberOfMessages) { - return getPreviousMessages(null, numberOfMessages); + return getChatMessages(null, null, numberOfMessages); + } + + /** + * Loads a range ending at the newest message while ensuring {@code oldestMessageId} is included, plus a buffer of + * messages older than that anchor. This is used for initial anchor scrolling: the anchor can be positioned at the + * viewport top while the user still has normally loaded messages above it. + * + * @param oldestMessageId message id that must be included in the returned batch + * @param bufferNumberOfMessages maximum number of messages to include before {@code oldestMessageId} + * @return message batch in ascending chronological order + */ + default ChatMessageBatch getLastChatMessages(int oldestMessageId, int bufferNumberOfMessages) { + return getChatMessages(null, oldestMessageId, bufferNumberOfMessages); } + /** + * Loads a contiguous range of chat messages in ascending chronological order. + *

+ * The two optional id parameters define the upper and lower boundaries: + *

    + *
  • {@code exclusiveStartingMessageId}: exclusive upper boundary. If set, only messages older than this id are returned.
  • + *
  • {@code inclusiveAnchorMessageId}: inclusive lower anchor. If set, the returned batch includes this message and all + * newer messages up to {@code exclusiveStartingMessageId} or the current newest message.
  • + *
+ * The {@code numberOfOlderMessages} parameter adds a bounded buffer before the active lower boundary. If + * {@code inclusiveAnchorMessageId} is set, the buffer is loaded before that message. Otherwise, the buffer is loaded before + * {@code exclusiveStartingMessageId}, or before the newest message when no upper boundary is set. + * + * @param exclusiveStartingMessageId exclusive upper message id, or {@code null} to include messages up to the current newest message + * @param inclusiveAnchorMessageId inclusive anchor message id, or {@code null} when no anchor needs to be forced into the result + * @param numberOfOlderMessages maximum number of messages to include before the active lower boundary + * @return message batch in ascending chronological order + */ + ChatMessageBatch getChatMessages(Integer exclusiveStartingMessageId, Integer inclusiveAnchorMessageId, int numberOfOlderMessages); + } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessage.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessage.java index 2558c2524..178f5fbbd 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessage.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessage.java @@ -33,6 +33,13 @@ public interface ChatMessage { String getText(); + default List getContent() { + if (getText() != null) { + return List.of(ChatMessageContent.text(getText())); + } + return List.of(); + } + default List getPhotos() { return List.of(); } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageBatch.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageBatch.java index 66f76b958..38761e2f0 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageBatch.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageBatch.java @@ -40,7 +40,11 @@ public boolean isContainsFirstMessage() { } public Integer getEarliestMessageId() { - return messages.size() > 0 ? messages.get(0).getId() : null; + return !messages.isEmpty() ? messages.get(0).getId() : null; + } + + public boolean containsMessage(int messageId) { + return messages.stream().anyMatch(message -> message.getId() == messageId); } } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageComponentContent.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageComponentContent.java new file mode 100644 index 000000000..cbc483cf6 --- /dev/null +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageComponentContent.java @@ -0,0 +1,25 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.teamapps.ux.component.chat; + +import org.teamapps.ux.component.Component; + +public record ChatMessageComponentContent(Component component) implements ChatMessageContent { +} diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageContent.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageContent.java new file mode 100644 index 000000000..1df5a1396 --- /dev/null +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageContent.java @@ -0,0 +1,34 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.teamapps.ux.component.chat; + +import org.teamapps.ux.component.Component; + +public interface ChatMessageContent { + + static ChatMessageContent text(String text) { + return new ChatMessageTextContent(text); + } + + static ChatMessageContent component(Component component) { + return new ChatMessageComponentContent(component); + } + +} diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageTextContent.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageTextContent.java new file mode 100644 index 000000000..fde6b53d6 --- /dev/null +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessageTextContent.java @@ -0,0 +1,23 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.teamapps.ux.component.chat; + +public record ChatMessageTextContent(String text) implements ChatMessageContent { +} diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatPhoto.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatPhoto.java index a015a3fad..6d68e4c48 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatPhoto.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatPhoto.java @@ -25,5 +25,8 @@ public interface ChatPhoto { Resolvable getThumbnail(); Resolvable getImage(); + default String getFileName() { + return null; + } } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/InMemoryChatDisplayModel.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/InMemoryChatDisplayModel.java index ffb0194e0..a2ed023e1 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/InMemoryChatDisplayModel.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/InMemoryChatDisplayModel.java @@ -65,20 +65,18 @@ public ChatMessage getChatMessageById(int id) { } @Override - public ChatMessageBatch getPreviousMessages(Integer earliestKnownMessageId, int numberOfMessages) { - if (earliestKnownMessageId == null) { - return readFromSnapshot(chatMessages -> { - int startIndex = Math.max(0, chatMessages.size() - numberOfMessages); - return new ChatMessageBatch(chatMessages.subList(startIndex, chatMessages.size()), startIndex == 0); - }); - } else { - return readFromSnapshot(chatMessages -> { - int searchResultIndex = Collections.binarySearch(chatMessages, new BinarySearchReferenceChatMessage(earliestKnownMessageId), Comparator.comparing(ChatMessage::getId)); - int firstKnownIndex = searchResultIndex >= 0 ? searchResultIndex : -(searchResultIndex + 1); - int startIndex = Math.max(0, firstKnownIndex - numberOfMessages); - return new ChatMessageBatch(chatMessages.subList(startIndex, firstKnownIndex), startIndex == 0); - }); - } + public ChatMessageBatch getChatMessages(Integer exclusiveStartingMessageId, Integer inclusiveAnchorMessageId, int numberOfOlderMessages) { + return readFromSnapshot(chatMessages -> { + int endIndex = exclusiveStartingMessageId != null ? findInsertionIndex(chatMessages, exclusiveStartingMessageId) : chatMessages.size(); + int lowerBoundaryIndex = inclusiveAnchorMessageId != null ? Math.min(findInsertionIndex(chatMessages, inclusiveAnchorMessageId), endIndex) : endIndex; + int startIndex = Math.max(0, lowerBoundaryIndex - Math.max(0, numberOfOlderMessages)); + return new ChatMessageBatch(chatMessages.subList(startIndex, endIndex), startIndex == 0); + }); + } + + private int findInsertionIndex(List chatMessages, int messageId) { + int searchResultIndex = Collections.binarySearch(chatMessages, new BinarySearchReferenceChatMessage(messageId), Comparator.comparing(ChatMessage::getId)); + return searchResultIndex >= 0 ? searchResultIndex : -(searchResultIndex + 1); } public ChatMessage addMessage(Resolvable userImage, String userNickname, String text) { @@ -86,9 +84,13 @@ public ChatMessage addMessage(Resolvable userImage, String userNickname, String } public ChatMessage addMessage(Resolvable userImage, String userNickname, String text, List photos, List files, boolean deleted) { + return addMessage(userImage, userNickname, text != null ? List.of(ChatMessageContent.text(text)) : List.of(), photos, files, deleted); + } + + public ChatMessage addMessage(Resolvable userImage, String userNickname, List content, List photos, List files, boolean deleted) { ChatMessageBatch chatMessageBatch = transformList(chatMessages -> { - boolean firstMessage = chatMessages.size() == 0; - SimpleChatMessage message = new SimpleChatMessage(chatMessageIdCounter.incrementAndGet(), userImage, userNickname, text, photos, files, deleted); + boolean firstMessage = chatMessages.isEmpty(); + SimpleChatMessage message = new SimpleChatMessage(chatMessageIdCounter.incrementAndGet(), userImage, userNickname, content, photos, files, deleted); chatMessages.add(message); return new ChatMessageBatch(Collections.singletonList(message), firstMessage); }); @@ -98,7 +100,7 @@ public ChatMessage addMessage(Resolvable userImage, String userNickname, String public void replaceAllMessages(List messages) { boolean wasChanged = transformList(chatMessages -> { - boolean changed = !(chatMessages.size() == 0 && messages.size() == 0); + boolean changed = !(chatMessages.isEmpty() && messages.isEmpty()); chatMessages.clear(); chatMessages.addAll(messages); return changed; diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/PhotoClickedEventData.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/PhotoClickedEventData.java new file mode 100644 index 000000000..7fb9b4495 --- /dev/null +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/PhotoClickedEventData.java @@ -0,0 +1,23 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.teamapps.ux.component.chat; + +public record PhotoClickedEventData(int messageId, int photoIndex) { +} diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/SimpleChatMessage.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/SimpleChatMessage.java index ebf725f74..6a23a206c 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/SimpleChatMessage.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/SimpleChatMessage.java @@ -22,13 +22,14 @@ import org.teamapps.ux.resolvable.Resolvable; import java.util.List; +import java.util.stream.Collectors; public class SimpleChatMessage implements ChatMessage { private final int id; private final Resolvable userImage; private final String userNickname; - private final String text; + private final List content; private final List photos; private final List files; private final boolean deleted; @@ -38,10 +39,14 @@ public SimpleChatMessage(int id, Resolvable userImage, String userNickname, Stri } public SimpleChatMessage(int id, Resolvable userImage, String userNickname, String text, List photos, List files, boolean deleted) { + this(id, userImage, userNickname, List.of(ChatMessageContent.text(text)), photos, files, deleted); + } + + public SimpleChatMessage(int id, Resolvable userImage, String userNickname, List content, List photos, List files, boolean deleted) { this.id = id; this.userImage = userImage; this.userNickname = userNickname; - this.text = text; + this.content = content == null ? List.of() : content; this.photos = photos; this.files = files; this.deleted = deleted; @@ -64,7 +69,16 @@ public String getUserNickname() { @Override public String getText() { - return text; + return content.stream() + .filter(ChatMessageTextContent.class::isInstance) + .map(ChatMessageTextContent.class::cast) + .map(ChatMessageTextContent::text) + .collect(Collectors.joining("\n")); + } + + @Override + public List getContent() { + return content; } @Override From d5a5cb4122cd0dab7f9bad051a3e94a355a5ca74 Mon Sep 17 00:00:00 2001 From: Hannes Drittler Date: Thu, 18 Jun 2026 15:07:21 +0200 Subject: [PATCH 2/5] Change ChatDisplay component: - add message footer - add message colors (border, bg, text) - enhance photo gallery rendering - stabilized scrolling position - lazy loading images --- .../less/components/UiChatDisplay.less | 178 ++++++++++- teamapps-client/ts/modules/UiChatDisplay.ts | 283 ++++++++++++++---- teamapps-ui-api/src/main/dto/UiChatView.dto | 4 + .../ux/component/chat/ChatDisplay.java | 4 + .../ux/component/chat/ChatMessage.java | 17 ++ .../chat/InMemoryChatDisplayModel.java | 11 +- .../ux/component/chat/SimpleChatMessage.java | 36 ++- 7 files changed, 461 insertions(+), 72 deletions(-) diff --git a/teamapps-client/less/components/UiChatDisplay.less b/teamapps-client/less/components/UiChatDisplay.less index 836e0abd1..478aae932 100644 --- a/teamapps-client/less/components/UiChatDisplay.less +++ b/teamapps-client/less/components/UiChatDisplay.less @@ -21,11 +21,32 @@ overflow: hidden auto; padding: 6px; + .photo-hover() { + transform: scale(.985); + object-fit: contain; + } + + .attachment-button(@padding: 0, @text-align: inherit) { + padding: @padding; + margin: 0; + border: 1px solid var(--ta-inner-border-color); + border-radius: var(--ta-border-radius-small); + background: var(--ta-bg-color-semi-transparent); + box-sizing: border-box; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + color: inherit; + font: inherit; + line-height: normal; + text-align: @text-align; + } + .message { display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto; - grid-template-areas: "userimage userNickname" "userimage content" "userimage photos" "userimage files"; + grid-template-areas: "userimage userNickname" "userimage body" "userimage footer"; .user-image { grid-area: userimage; @@ -40,10 +61,19 @@ } .user-nickname { grid-area: userNickname; + min-width: 0; font-weight: bold; } + .message-body { + grid-area: body; + min-width: 0; + } + &.decorated .message-body { + padding: 6px 8px; + border-radius: var(--ta-border-radius-small); + } .content { - grid-area: content; + min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -53,30 +83,109 @@ } } .photos { - grid-area: photos; + min-width: 0; + display: grid; + gap: 4px; + margin-top: 4px; + width: 100%; + box-sizing: border-box; + + --photo-tile-size: min(85px, calc((100% - 28px) / 8)); + + grid-template-columns: repeat(8, minmax(0, var(--photo-tile-size))); + grid-auto-rows: var(--photo-tile-size); + grid-auto-flow: dense; + justify-content: start; + + &.photos-count-1 { + display: block; + + .photo-tile { + display: block; + width: 350px; + max-width: 100%; + aspect-ratio: 1 / 1; + max-height: 350px; + &.square, &.portrait, &.landscape { + display: inline-block; + width: auto; + height: auto; + aspect-ratio: auto; + } + } + .photo { + position: static; + width: auto; + max-width: 100%; + height: auto; + max-height: 350px; + transform-origin: center center; + } + } + .photo-tile { + .attachment-button(); + position: relative; + display: block; + grid-column: span 2; + grid-row: span 2; + overflow: hidden; + } .photo { - width: auto; - margin: 2px 0; - max-width: 100%; - max-height: 600px; + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center center; + } + .photo-tile:focus-visible .photo { + .photo-hover(); + } + .photo-tile.portrait { + grid-column: span 2; + grid-row: span 4; + } + .photo-tile.landscape { + grid-column: span 4; + grid-row: span 2; } } .files { - grid-area: files; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + margin-top: 4px; .file { - display: grid; - grid-template-columns: auto 1fr; + display: inline-grid; + grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto; grid-template-areas: "icon name" "icon size"; - margin: 2px 0; + align-items: center; + column-gap: 6px; + max-width: 100%; + .attachment-button(4px 8px 4px 4px, left); + color: var(--ta-link-color); + vertical-align: top; + + &:hover, + &:focus-visible { + color: var(--ta-link-hover-color); + text-decoration: underline; + } .file-icon { grid-area: icon; - margin-right: 5px; } .file-name { grid-area: name; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .file-size { grid-area: size; @@ -84,19 +193,34 @@ } } } - &:not(.deleted) .deleted-icon{ + .content:empty, + .photos:empty, + .files:empty, + .message-footer:empty { + display: none; + } + .message-footer { + grid-area: footer; + min-width: 0; + margin-top: 2px; + color: var(--ta-text-color); + font-size: 11px; + text-align: right; + opacity: .75; + } + &:not(.deleted) .deleted-icon { display: none; } &.deleted { filter: grayscale(1) opacity(0.5); - grid-template-columns: auto auto 1fr; + grid-template-columns: auto auto minmax(0, 1fr); grid-template-areas: "userimage userNickname deletedicon"; align-items: center; //.user-nickname { // align-self: center; //} - .content, .photos, .files { + .message-body, .message-footer { display: none; } @@ -112,4 +236,26 @@ margin-bottom: 3px; } } + + @media (max-width: 880px) { + .message { + .photos { + grid-template-columns: repeat(4, minmax(0, min(80px, calc((100% - 12px) / 4)))); + } + } + } + + @media (max-width: 480px) { + .message { + .photos { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + } + + @media (hover: hover) and (pointer: fine) { + .message .photos .photo-tile:hover .photo { + .photo-hover(); + } + } } diff --git a/teamapps-client/ts/modules/UiChatDisplay.ts b/teamapps-client/ts/modules/UiChatDisplay.ts index 0a8979678..96122c1f4 100644 --- a/teamapps-client/ts/modules/UiChatDisplay.ts +++ b/teamapps-client/ts/modules/UiChatDisplay.ts @@ -18,6 +18,7 @@ * =========================LICENSE_END================================== */ import {Autolinker} from "autolinker"; +import ResizeObserver from "resize-observer-polyfill"; import { UiChatDisplay_MessageViewedEvent, UiChatDisplay_PhotoClickedEvent, @@ -40,6 +41,8 @@ import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; type MessageScrollAlignment = "top" | "bottom" | "nearest"; +type PhotoTileClass = 'portrait' | 'square' | 'landscape'; +interface IScrollAnchor { message: UiChatMessage; offsetTop: number; } export class UiChatDisplay extends AbstractUiComponent implements UiChatDisplayCommandHandler, UiChatDisplayEventSource { @@ -55,6 +58,9 @@ export class UiChatDisplay extends AbstractUiComponent impl private contextMenu: ContextMenu; private lastMessageViewedEventMessageId = Number.NEGATIVE_INFINITY; private viewedEventsEnabled = false; + private stickToBottom = false; + private scrollAnchor: IScrollAnchor | null = null; + private readonly messageResizeObserver = new ResizeObserver(entries => this.handleMessagesResized(entries)); constructor(config: UiChatDisplayConfig, context: TeamAppsUiContext) { super(config, context); @@ -73,6 +79,7 @@ export class UiChatDisplay extends AbstractUiComponent impl this.$loadingIndicatorWrapper.classList.remove('hidden'); this.requestPreviousMessages(); } + this.rememberScrollAnchor(); this.fireMessageViewedForNewestVisibleMessage(); }); @@ -112,12 +119,13 @@ export class UiChatDisplay extends AbstractUiComponent impl this._config.requestPreviousMessages({}).then(batch => { const heightBefore = this.$messages.offsetHeight; batch.messages.reverse().forEach(messageConfig => { - const uiChatMessage = new UiChatMessage(messageConfig, this.handlePhotoClicked); + const uiChatMessage = new UiChatMessage(messageConfig, this); prependChild(this.$messages, uiChatMessage.getMainDomElement()); this.uiChatMessages.splice(0, 0, uiChatMessage); + this.observeMessage(uiChatMessage); }); const heightAfter = this.$messages.offsetHeight; - this.setScrollTop(heightAfter - heightBefore, "auto"); + this.scrollToTopPositionNow(heightAfter - heightBefore, "auto"); if (batch.containsFirstMessage) { this.gotFirstMessage = true; @@ -139,9 +147,10 @@ export class UiChatDisplay extends AbstractUiComponent impl private appendMessages(batch: UiChatMessageBatchConfig): void { batch.messages.forEach(messageConfig => { - const chatMessage = new UiChatMessage(messageConfig, this.handlePhotoClicked); + const chatMessage = new UiChatMessage(messageConfig, this); this.$messages.appendChild(chatMessage.getMainDomElement()); this.uiChatMessages.push(chatMessage); + this.observeMessage(chatMessage); }); if (batch.containsFirstMessage) { this.gotFirstMessage = true; @@ -149,22 +158,30 @@ export class UiChatDisplay extends AbstractUiComponent impl } public updateMessage(message: UiChatMessageConfig) { - this.uiChatMessages.find(m => m.id === message.id) - ?.update(message); - this.doWhenAllImagesAreLoaded(() => this.fireMessageViewedForNewestVisibleMessage()); + const uiChatMessage = this.uiChatMessages.find(m => m.id === message.id); + if (uiChatMessage != null) { + this.unobserveMessage(uiChatMessage); + uiChatMessage.update(message); + this.observeMessage(uiChatMessage); + } + requestAnimationFrame(() => this.fireMessageViewedForNewestVisibleMessage()); } public deleteMessage(messageId: number) { const messageIndex = this.uiChatMessages.findIndex(m => m.id === messageId); if (messageIndex >= 0) { const uiChatMessage = this.uiChatMessages[messageIndex]; + this.unobserveMessage(uiChatMessage); uiChatMessage.getMainDomElement().remove(); this.uiChatMessages.splice(messageIndex, 1); + this.rememberScrollAnchor(); this.fireMessageViewedForNewestVisibleMessage(); } } public clearMessages(batch: UiChatMessageBatchConfig): void { + this.messageResizeObserver.disconnect(); + this.scrollAnchor = null; this.uiChatMessages = []; this.$messages.innerHTML = ''; this.gotFirstMessage = false; @@ -175,22 +192,22 @@ export class UiChatDisplay extends AbstractUiComponent impl } public scrollToMessage(messageId: number): void { - this.doWhenAllImagesAreLoaded(() => this.scrollToMessageById(messageId, "nearest", "smooth", true)); + requestAnimationFrame(() => this.scrollToMessageNow(messageId, "nearest", "smooth", true)); } - private scrollToMessageById(messageId: number, alignment: MessageScrollAlignment, behavior: ScrollBehavior, fireViewed: boolean): boolean { + private scrollToMessageNow(messageId: number, alignment: MessageScrollAlignment, behavior: ScrollBehavior, fireViewed: boolean): boolean { const message = this.uiChatMessages.find(m => m.id === messageId); if (message == null) { return false; } - this.setScrollTop(this.getScrollTopForMessage(message.getMainDomElement(), alignment), behavior); + this.scrollToTopPositionNow(this.getScrollTopPositionForMessage(message.getMainDomElement(), alignment), behavior); if (fireViewed) { this.fireMessageViewedAfterScroll(behavior); } return true; } - private getScrollTopForMessage($message: HTMLElement, alignment: MessageScrollAlignment): number { + private getScrollTopPositionForMessage($message: HTMLElement, alignment: MessageScrollAlignment): number { const messageTop = $message.offsetTop; const messageBottom = messageTop + $message.offsetHeight; if (alignment === "top" || $message.offsetHeight > this.$main.clientHeight) { @@ -215,13 +232,13 @@ export class UiChatDisplay extends AbstractUiComponent impl } private applyScrollPosition(topMessageId?: number, alignment: MessageScrollAlignment = "top") { - this.doWhenAllImagesAreLoaded(() => { + requestAnimationFrame(() => { let initialMessageFound = false; if (topMessageId != null) { - initialMessageFound = this.scrollToMessageById(topMessageId, alignment, "auto", false); + initialMessageFound = this.scrollToMessageNow(topMessageId, alignment, "auto", false); } if (!initialMessageFound) { - this.scrollToBottom("auto", false); + this.scrollToBottomNow("auto"); } requestAnimationFrame(() => { this.viewedEventsEnabled = true; @@ -231,23 +248,82 @@ export class UiChatDisplay extends AbstractUiComponent impl } private scrollToBottom(behavior: ScrollBehavior, fireViewed: boolean) { - this.doWhenAllImagesAreLoaded(() => { - this.setScrollTop(this.$main.scrollHeight - this.$main.clientHeight, behavior); + requestAnimationFrame(() => { + this.scrollToBottomNow(behavior); + if (behavior === "smooth") { + setTimeout(() => this.scrollToBottomNow("auto"), 350); // needed if layout shifts while scrolling + } if (fireViewed) { this.fireMessageViewedAfterScroll(behavior); } }); } - private setScrollTop(scrollTop: number, behavior: ScrollBehavior) { - const top = Math.max(0, scrollTop); + private scrollToBottomNow(behavior: ScrollBehavior) { + this.scrollToTopPositionNow(this.$main.scrollHeight - this.$main.clientHeight, behavior); + this.stickToBottom = true; + } + + private scrollToTopPositionNow(scrollTopPosition: number, behavior: ScrollBehavior) { + const top = Math.max(0, scrollTopPosition); if (behavior === "auto") { this.$main.scrollTop = top; + this.rememberScrollAnchor(); } else { + this.stickToBottom = false; this.$main.scroll({top, behavior}); } } + private isScrolledToBottom(): boolean { + return this.$main.scrollTop + this.$main.clientHeight + 5 >= this.$main.scrollHeight; + } + + private observeMessage(message: UiChatMessage): void { + this.messageResizeObserver.observe(message.getMainDomElement()); + } + + private unobserveMessage(message: UiChatMessage): void { + this.messageResizeObserver.unobserve(message.getMainDomElement()); + } + + private handleMessagesResized(entries: ResizeObserverEntry[]): void { + if (this.stickToBottom) { + this.scrollToBottomNow("auto"); + return; + } + + const anchor = this.scrollAnchor; + const anchorId = anchor != null ? anchor.message.id : Number.NEGATIVE_INFINITY; + if (anchorId >= 0 && entries.some(entry => { + const messageId = Number((entry.target as HTMLElement).getAttribute("data-id") ?? Number.NEGATIVE_INFINITY); + return messageId >= 0 && messageId < anchorId; + })) { + this.restoreScrollAnchor(anchor); + } + } + + private rememberScrollAnchor(): void { + this.stickToBottom = this.isScrolledToBottom(); + const viewportRect = this.$main.getBoundingClientRect(); + const message = this.uiChatMessages.find(msg => msg.getMainDomElement().getBoundingClientRect().bottom > viewportRect.top); + this.scrollAnchor = message != null ? { + message, + offsetTop: message.getMainDomElement().getBoundingClientRect().top - viewportRect.top, + } : null; + } + + private restoreScrollAnchor(anchor: IScrollAnchor): void { + if (anchor == null || this.uiChatMessages.indexOf(anchor.message) < 0) { + this.rememberScrollAnchor(); + return; + } + const viewportTop = this.$main.getBoundingClientRect().top; + const anchorTop = anchor.message.getMainDomElement().getBoundingClientRect().top; + this.$main.scrollTop += anchorTop - viewportTop - anchor.offsetTop; + this.rememberScrollAnchor(); + } + private fireMessageViewedAfterScroll(behavior: ScrollBehavior) { if (behavior === "smooth") { setTimeout(() => this.fireMessageViewedForNewestVisibleMessage(), 300); @@ -256,21 +332,6 @@ export class UiChatDisplay extends AbstractUiComponent impl } } - private doWhenAllImagesAreLoaded(action: () => void) { - const allImages = Array.from(this.$messages.querySelectorAll(":scope img")); - const incompleteImages: HTMLImageElement[] = allImages - .map(img => img as HTMLImageElement) - .filter(img => !img.complete); - if (incompleteImages.length > 0) { - Promise.all(incompleteImages.map(img => new Promise((resolve, reject) => { - img.addEventListener("load", resolve, {once: true}); - img.addEventListener("error", resolve, {once: true}); // since we are only interested in when loading activity is done - }))).then(action); - } else { - action(); - } - } - private fireMessageViewedForNewestVisibleMessage() { if (!this.viewedEventsEnabled || this.$main.clientHeight <= 0) { return; @@ -293,17 +354,18 @@ export class UiChatDisplay extends AbstractUiComponent impl return messageRect.bottom > viewportRect.top && messageRect.top < viewportRect.bottom; } - private readonly handlePhotoClicked = (messageId: number, photoIndex: number) => { - this.onPhotoClicked.fire({messageId, photoIndex}); - } - public onResize() { - this.doWhenAllImagesAreLoaded(() => this.fireMessageViewedForNewestVisibleMessage()); + requestAnimationFrame(() => this.fireMessageViewedForNewestVisibleMessage()); } public closeContextMenu(): void { this.contextMenu.close(); } + + public destroy(): void { + this.messageResizeObserver.disconnect(); + super.destroy(); + } } TeamAppsUiComponentRegistry.registerComponentClass("UiChatDisplay", UiChatDisplay); @@ -332,16 +394,23 @@ class UiChatMessage { className: '' }); - - private $main: HTMLElement; + private static readonly TILE_RATIOS: Record = { + portrait: 0.5, + square: 1, + landscape: 2, + }; + + private readonly parent: UiChatDisplay; + private readonly $main: HTMLElement; + private $body: HTMLElement; private $content: HTMLElement; private $photos: HTMLElement; private $files: HTMLElement; + private $footer: HTMLElement; private config: UiChatMessageConfig; - private readonly onPhotoClicked: (messageId: number, photoIndex: number) => void; - constructor(config: UiChatMessageConfig, onPhotoClicked: (messageId: number, photoIndex: number) => void) { - this.onPhotoClicked = onPhotoClicked; + constructor(config: UiChatMessageConfig, parent: UiChatDisplay) { + this.parent = parent; this.$main = parseHtml(`
`); this.update(config); } @@ -349,41 +418,91 @@ class UiChatMessage { public update(config: UiChatMessageConfig) { this.config = config; this.$main.classList.toggle("deleted", config.deleted); + this.$main.classList.toggle("decorated", this.hasFrameDecoration()); this.$main.innerHTML = ""; this.$main.appendChild(parseHtml(``)); this.$main.appendChild(parseHtml(`
${this.config.userNickname}
`)); - this.$main.appendChild(parseHtml(`
`)); - this.$main.appendChild(parseHtml(`
`)); - this.$main.appendChild(parseHtml(`
`)); + this.$main.appendChild(parseHtml(`
+
+
+
+
`)); + this.$main.appendChild(parseHtml(``)); this.$main.appendChild(parseHtml(`
`)); + this.$body = this.$main.querySelector(":scope .message-body"); this.$content = this.$main.querySelector(":scope .content"); this.$photos = this.$main.querySelector(":scope .photos"); this.$files = this.$main.querySelector(":scope .files"); + this.$footer = this.$main.querySelector(":scope .message-footer"); + if (this.config.backgroundColor != null) { + this.$body.style.backgroundColor = this.config.backgroundColor; + } + if (this.config.borderColor != null) { + this.$body.style.border = `1px solid ${this.config.borderColor}`; + } + if (this.config.textColor != null) { + this.$body.style.color = this.config.textColor; + } this.getContent().forEach(content => this.renderContent(content)); if (this.config.photos != null) { + this.$photos.classList.add(`photos-count-${Math.min(this.config.photos.length, 4)}`); + this.config.photos.forEach((photo, photoIndex) => { const $photo = document.createElement("img"); $photo.classList.add("photo"); - $photo.src = photo.thumbnailUrl ?? photo.imageUrl; + $photo.setAttribute("loading", "lazy"); + $photo.setAttribute("decoding", "async"); $photo.title = photo.fileName ?? ""; - $photo.addEventListener("click", ev => { + $photo.alt = photo.fileName ?? ""; + + const $tile = document.createElement("button"); + $tile.type = "button"; + $tile.classList.add("photo-tile"); + + $tile.addEventListener("click", ev => { ev.preventDefault(); - this.onPhotoClicked(this.config.id, photoIndex); + this.parent.onPhotoClicked.fire({messageId: this.config.id, photoIndex}); + }); + + $photo.addEventListener("load", () => { + this.applyPhotoTileLayout($tile, $photo); }); - this.$photos.appendChild($photo); + + $tile.appendChild($photo); + this.$photos.appendChild($tile); + $photo.src = photo.thumbnailUrl ?? photo.imageUrl; + this.applyPhotoTileLayoutWhenDimensionsAreAvailable($tile, $photo); }); } if (this.config.files != null) { this.config.files.forEach(file => { - this.$files.appendChild(parseHtml(` -
-
${file.name}
-
${humanReadableFileSize(file.length)}
-
`)); + const $file = parseHtml(``); + $file.addEventListener("click", () => { + if (file.downloadUrl != null) { + this.downloadFile(file.downloadUrl, file.name); + } + }); + const $fileName = $file.querySelector(":scope .file-name"); + $fileName.textContent = file.name ?? ""; + const $fileSize = $file.querySelector(":scope .file-size"); + $fileSize.textContent = humanReadableFileSize(file.length ?? 0); + const $fileIcon = $file.querySelector(":scope .file-icon") as HTMLElement; + const fileIconUrl = file.thumbnailUrl || file.icon; + if (fileIconUrl != null) { + $fileIcon.style.backgroundImage = `url('${fileIconUrl}')`; + } + this.$files.appendChild($file); }); } + if (this.config.footer != null && this.config.footer.length > 0) { + this.$footer.appendChild(this.createFooterElement(this.config.footer)); + } } private getContent(): UiChatMessageContentConfig[] { @@ -393,6 +512,10 @@ class UiChatMessage { return this.config.text != null ? [{text: this.config.text}] : []; } + private hasFrameDecoration(): boolean { + return this.config.backgroundColor != null || this.config.borderColor != null; + } + private renderContent(content: UiChatMessageContentConfig) { if (content.component != null) { const $componentWrapper = parseHtml(`
`); @@ -410,6 +533,58 @@ class UiChatMessage { return parseHtml(`
${text}
`); } + private createFooterElement(text: string) { + text = removeDangerousTags(text); + text = UiChatMessage.AUTOLINKER.link(text); + return parseHtml(``); + } + + private downloadFile(url: string, fileName?: string): void { + const $link = document.createElement("a"); + $link.href = url; + $link.download = fileName ?? ""; + $link.style.display = "none"; + document.body.appendChild($link); + $link.click(); + $link.remove(); + } + + private applyPhotoTileLayout($tile: HTMLElement, $photo: HTMLImageElement): void { + const width = $photo.naturalWidth; + const height = $photo.naturalHeight; + + if (width <= 0 || height <= 0) { + return; + } + + const tileClass = this.getPhotoTileClass(width, height); + $tile.classList.remove("portrait", "square", "landscape"); + $tile.classList.add(tileClass); + } + + private applyPhotoTileLayoutWhenDimensionsAreAvailable($tile: HTMLElement, $photo: HTMLImageElement): void { + let attempt = 0; + const checkDimensions = () => { + if ($photo.naturalWidth > 0 && $photo.naturalHeight > 0) { + this.applyPhotoTileLayout($tile, $photo); + } else if (!$photo.complete && attempt++ < 20) { + requestAnimationFrame(checkDimensions); + } + }; + requestAnimationFrame(checkDimensions); + } + + private getPhotoTileClass(width: number, height: number): PhotoTileClass { + const imageRatio = width / height; + + return (Object.entries(UiChatMessage.TILE_RATIOS) as Array<[PhotoTileClass, number]>) + .map(([className, tileRatio]) => ({ + className, + distance: Math.abs(Math.log(imageRatio / tileRatio)), + })) + .sort((a, b) => a.distance - b.distance)[0].className; + } + public get id() { return this.config.id; } diff --git a/teamapps-ui-api/src/main/dto/UiChatView.dto b/teamapps-ui-api/src/main/dto/UiChatView.dto index 4490ade88..f7747032a 100644 --- a/teamapps-ui-api/src/main/dto/UiChatView.dto +++ b/teamapps-ui-api/src/main/dto/UiChatView.dto @@ -69,6 +69,10 @@ class UiChatMessage { List content; List photos; List files; + String backgroundColor; + String borderColor; + String textColor; + String footer; boolean deleted; } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java index 10e70ae19..1ea84705e 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java @@ -162,6 +162,10 @@ private UiChatMessage createUiChatMessage(ChatMessage message) { uiChatMessage.setFiles(message.getFiles() != null ? message.getFiles().stream() .map(this::createUiChatFile) .collect(Collectors.toList()) : null); + uiChatMessage.setBackgroundColor(message.getBackgroundColor() != null ? message.getBackgroundColor().toHtmlColorString() : null); + uiChatMessage.setBorderColor(message.getBorderColor() != null ? message.getBorderColor().toHtmlColorString() : null); + uiChatMessage.setTextColor(message.getTextColor() != null ? message.getTextColor().toHtmlColorString() : null); + uiChatMessage.setFooter(message.getFooter()); uiChatMessage.setDeleted(message.isDeleted()); return uiChatMessage; } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessage.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessage.java index 178f5fbbd..86304c9f1 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessage.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatMessage.java @@ -19,6 +19,7 @@ */ package org.teamapps.ux.component.chat; +import org.teamapps.common.format.Color; import org.teamapps.ux.resolvable.Resolvable; import java.util.List; @@ -48,6 +49,22 @@ default List getFiles() { return List.of(); } + default Color getBackgroundColor() { + return null; + } + + default Color getBorderColor() { + return null; + } + + default Color getTextColor() { + return null; + } + + default String getFooter() { + return null; + } + default boolean isDeleted() { return false; } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/InMemoryChatDisplayModel.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/InMemoryChatDisplayModel.java index a2ed023e1..1a8f941c4 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/InMemoryChatDisplayModel.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/InMemoryChatDisplayModel.java @@ -19,6 +19,7 @@ */ package org.teamapps.ux.component.chat; +import org.teamapps.common.format.Color; import org.teamapps.ux.resolvable.Resolvable; import java.util.ArrayList; @@ -88,9 +89,17 @@ public ChatMessage addMessage(Resolvable userImage, String userNickname, String } public ChatMessage addMessage(Resolvable userImage, String userNickname, List content, List photos, List files, boolean deleted) { + return addMessage(userImage, userNickname, content, photos, files, deleted, null, null, null, null); + } + + public ChatMessage addMessage(Resolvable userImage, String userNickname, List content, List photos, List files, boolean deleted, String footer) { + return addMessage(userImage, userNickname, content, photos, files, deleted, null, null, null, footer); + } + + public ChatMessage addMessage(Resolvable userImage, String userNickname, List content, List photos, List files, boolean deleted, Color backgroundColor, Color borderColor, Color textColor, String footer) { ChatMessageBatch chatMessageBatch = transformList(chatMessages -> { boolean firstMessage = chatMessages.isEmpty(); - SimpleChatMessage message = new SimpleChatMessage(chatMessageIdCounter.incrementAndGet(), userImage, userNickname, content, photos, files, deleted); + SimpleChatMessage message = new SimpleChatMessage(chatMessageIdCounter.incrementAndGet(), userImage, userNickname, content, photos, files, deleted, backgroundColor, borderColor, textColor, footer); chatMessages.add(message); return new ChatMessageBatch(Collections.singletonList(message), firstMessage); }); diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/SimpleChatMessage.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/SimpleChatMessage.java index 6a23a206c..f80b8c58b 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/SimpleChatMessage.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/SimpleChatMessage.java @@ -19,6 +19,7 @@ */ package org.teamapps.ux.component.chat; +import org.teamapps.common.format.Color; import org.teamapps.ux.resolvable.Resolvable; import java.util.List; @@ -32,6 +33,10 @@ public class SimpleChatMessage implements ChatMessage { private final List content; private final List photos; private final List files; + private final Color backgroundColor; + private final Color borderColor; + private final Color textColor; + private final String footer; private final boolean deleted; public SimpleChatMessage(int id, Resolvable userImage, String userNickname, String text) { @@ -39,16 +44,24 @@ public SimpleChatMessage(int id, Resolvable userImage, String userNickname, Stri } public SimpleChatMessage(int id, Resolvable userImage, String userNickname, String text, List photos, List files, boolean deleted) { - this(id, userImage, userNickname, List.of(ChatMessageContent.text(text)), photos, files, deleted); + this(id, userImage, userNickname, text != null ? List.of(ChatMessageContent.text(text)) : List.of(), photos, files, deleted); } public SimpleChatMessage(int id, Resolvable userImage, String userNickname, List content, List photos, List files, boolean deleted) { + this(id, userImage, userNickname, content, photos, files, deleted, null, null, null, null); + } + + public SimpleChatMessage(int id, Resolvable userImage, String userNickname, List content, List photos, List files, boolean deleted, Color backgroundColor, Color borderColor, Color textColor, String footer) { this.id = id; this.userImage = userImage; this.userNickname = userNickname; this.content = content == null ? List.of() : content; this.photos = photos; this.files = files; + this.backgroundColor = backgroundColor; + this.borderColor = borderColor; + this.textColor = textColor; + this.footer = footer; this.deleted = deleted; } @@ -91,6 +104,27 @@ public List getFiles() { return files; } + @Override + public Color getBackgroundColor() { + return backgroundColor; + } + + @Override + public Color getBorderColor() { + return borderColor; + } + + @Override + public Color getTextColor() { + return textColor; + } + + @Override + public String getFooter() { + return footer; + } + + @Override public boolean isDeleted() { return deleted; } From 0337ea6cce1839e7215a54d6a6b5832b3bd3209c Mon Sep 17 00:00:00 2001 From: Hannes Drittler Date: Thu, 25 Jun 2026 17:06:00 +0200 Subject: [PATCH 3/5] Extracting MasonryGallery UI component from ChatDisplay and rollback to its previous handling of photos --- .../less/components/UiChatDisplay.less | 88 ++--------- .../less/components/UiMasonryGallery.less | 147 ++++++++++++++++++ teamapps-client/less/teamapps.less | 1 + teamapps-client/ts/modules/UiChatDisplay.ts | 50 ------ .../ts/modules/UiMasonryGallery.ts | 123 +++++++++++++++ teamapps-client/ts/modules/index.ts | 1 + .../src/main/dto/UiMasonryGallery.dto | 32 ++++ .../masonrygallery/MasonryGallery.java | 77 +++++++++ .../masonrygallery/MasonryGalleryImage.java | 53 +++++++ 9 files changed, 444 insertions(+), 128 deletions(-) create mode 100644 teamapps-client/less/components/UiMasonryGallery.less create mode 100644 teamapps-client/ts/modules/UiMasonryGallery.ts create mode 100644 teamapps-ui-api/src/main/dto/UiMasonryGallery.dto create mode 100644 teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGallery.java create mode 100644 teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGalleryImage.java diff --git a/teamapps-client/less/components/UiChatDisplay.less b/teamapps-client/less/components/UiChatDisplay.less index 478aae932..98b314482 100644 --- a/teamapps-client/less/components/UiChatDisplay.less +++ b/teamapps-client/less/components/UiChatDisplay.less @@ -21,11 +21,6 @@ overflow: hidden auto; padding: 6px; - .photo-hover() { - transform: scale(.985); - object-fit: contain; - } - .attachment-button(@padding: 0, @text-align: inherit) { padding: @padding; margin: 0; @@ -84,71 +79,24 @@ } .photos { min-width: 0; - display: grid; + display: flex; + flex-wrap: wrap; + align-items: flex-start; gap: 4px; margin-top: 4px; - width: 100%; - box-sizing: border-box; - - --photo-tile-size: min(85px, calc((100% - 28px) / 8)); - - grid-template-columns: repeat(8, minmax(0, var(--photo-tile-size))); - grid-auto-rows: var(--photo-tile-size); - grid-auto-flow: dense; - justify-content: start; - &.photos-count-1 { - display: block; - - .photo-tile { - display: block; - width: 350px; - max-width: 100%; - aspect-ratio: 1 / 1; - max-height: 350px; - &.square, &.portrait, &.landscape { - display: inline-block; - width: auto; - height: auto; - aspect-ratio: auto; - } - } - .photo { - position: static; - width: auto; - max-width: 100%; - height: auto; - max-height: 350px; - transform-origin: center center; - } - } .photo-tile { .attachment-button(); - position: relative; display: block; - grid-column: span 2; - grid-row: span 2; - overflow: hidden; + width: auto; + max-width: 525px; } .photo { - position: absolute; - inset: 0; display: block; - width: 100%; - height: 100%; - object-fit: cover; - object-position: center center; - } - .photo-tile:focus-visible .photo { - .photo-hover(); - } - .photo-tile.portrait { - grid-column: span 2; - grid-row: span 4; - } - .photo-tile.landscape { - grid-column: span 4; - grid-row: span 2; + width: auto; + height: auto; + max-width: 100%; + max-height: 350px; } } .files { @@ -237,25 +185,9 @@ } } - @media (max-width: 880px) { - .message { - .photos { - grid-template-columns: repeat(4, minmax(0, min(80px, calc((100% - 12px) / 4)))); - } - } - } - - @media (max-width: 480px) { - .message { - .photos { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - } - } - @media (hover: hover) and (pointer: fine) { .message .photos .photo-tile:hover .photo { - .photo-hover(); + opacity: .85; } } } diff --git a/teamapps-client/less/components/UiMasonryGallery.less b/teamapps-client/less/components/UiMasonryGallery.less new file mode 100644 index 000000000..2c6b81ad6 --- /dev/null +++ b/teamapps-client/less/components/UiMasonryGallery.less @@ -0,0 +1,147 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +.UiMasonryGallery { + --image-cell-size: min(174px, calc((100% - 4px) / 2)); + + display: grid; + grid-template-columns: repeat(auto-fill, var(--image-cell-size)); + grid-auto-rows: var(--image-cell-size); + grid-auto-flow: dense; + justify-content: start; + gap: 4px; + min-width: 0; + width: 100%; + box-sizing: border-box; + + .image-tile { + position: relative; + display: block; + grid-column: span 1; + grid-row: span 1; + padding: 0; + margin: 0; + overflow: hidden; + border: 1px solid var(--ta-inner-border-color); + border-radius: var(--ta-border-radius-small); + background: var(--ta-bg-color-semi-transparent); + box-sizing: border-box; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + + /* Background layer with blured image. */ + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + background-image: var(--tile-image); + background-size: cover; + background-repeat: no-repeat; + background-position: center center; + + opacity: 1; + transform: scale(1); + pointer-events: none; + } + } + + .image { + position: absolute; + inset: 0; + z-index: 1; + display: block; + width: 100%; + height: 100%; + + object-fit: contain; + opacity: 0; + } + + .image-tile.portrait { + grid-column: span 1; + grid-row: span 2; + } + + .image-tile.landscape { + grid-column: span 2; + grid-row: span 1; + } + + &.image-count-1 { + display: block; + + .image-tile { + display: block; + width: 350px; + max-width: 100%; + max-height: 350px; + aspect-ratio: 1 / 1; + + &.square, + &.portrait, + &.landscape { + display: inline-block; + width: auto; + height: auto; + aspect-ratio: auto; + } + + &::before { + display: none; + } + } + + .image { + position: static; + width: auto; + max-width: 100%; + height: auto; + max-height: 350px; + opacity: 1; + } + } + + .image-tile-effect() { + opacity: .35; + filter: blur(7px); + } + .image-effect() { + opacity: 1; + transform: scale(.98); + } + + .image-tile:focus-visible::before { + .image-tile-effect(); + } + .image-tile:focus-visible .image { + .image-effect(); + } + + @media (hover: hover) and (pointer: fine) { + .image-tile:hover::before { + .image-tile-effect(); + } + .image-tile:hover .image { + .image-effect(); + } + } + +} diff --git a/teamapps-client/less/teamapps.less b/teamapps-client/less/teamapps.less index 182181de7..c3e282e57 100644 --- a/teamapps-client/less/teamapps.less +++ b/teamapps-client/less/teamapps.less @@ -92,6 +92,7 @@ @import "components/UiFlexContainer"; @import "components/UiChatDisplay"; @import "components/UiChatInput"; +@import "components/UiMasonryGallery"; @import "components/UiAbsoluteLayout"; @import "components/UiTreeGraph"; @import "components/UiFieldGroup"; diff --git a/teamapps-client/ts/modules/UiChatDisplay.ts b/teamapps-client/ts/modules/UiChatDisplay.ts index 96122c1f4..1ad16fd53 100644 --- a/teamapps-client/ts/modules/UiChatDisplay.ts +++ b/teamapps-client/ts/modules/UiChatDisplay.ts @@ -41,7 +41,6 @@ import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; type MessageScrollAlignment = "top" | "bottom" | "nearest"; -type PhotoTileClass = 'portrait' | 'square' | 'landscape'; interface IScrollAnchor { message: UiChatMessage; offsetTop: number; } export class UiChatDisplay extends AbstractUiComponent implements UiChatDisplayCommandHandler, UiChatDisplayEventSource { @@ -394,12 +393,6 @@ class UiChatMessage { className: '' }); - private static readonly TILE_RATIOS: Record = { - portrait: 0.5, - square: 1, - landscape: 2, - }; - private readonly parent: UiChatDisplay; private readonly $main: HTMLElement; private $body: HTMLElement; @@ -447,8 +440,6 @@ class UiChatMessage { this.getContent().forEach(content => this.renderContent(content)); if (this.config.photos != null) { - this.$photos.classList.add(`photos-count-${Math.min(this.config.photos.length, 4)}`); - this.config.photos.forEach((photo, photoIndex) => { const $photo = document.createElement("img"); $photo.classList.add("photo"); @@ -466,14 +457,9 @@ class UiChatMessage { this.parent.onPhotoClicked.fire({messageId: this.config.id, photoIndex}); }); - $photo.addEventListener("load", () => { - this.applyPhotoTileLayout($tile, $photo); - }); - $tile.appendChild($photo); this.$photos.appendChild($tile); $photo.src = photo.thumbnailUrl ?? photo.imageUrl; - this.applyPhotoTileLayoutWhenDimensionsAreAvailable($tile, $photo); }); } if (this.config.files != null) { @@ -549,42 +535,6 @@ class UiChatMessage { $link.remove(); } - private applyPhotoTileLayout($tile: HTMLElement, $photo: HTMLImageElement): void { - const width = $photo.naturalWidth; - const height = $photo.naturalHeight; - - if (width <= 0 || height <= 0) { - return; - } - - const tileClass = this.getPhotoTileClass(width, height); - $tile.classList.remove("portrait", "square", "landscape"); - $tile.classList.add(tileClass); - } - - private applyPhotoTileLayoutWhenDimensionsAreAvailable($tile: HTMLElement, $photo: HTMLImageElement): void { - let attempt = 0; - const checkDimensions = () => { - if ($photo.naturalWidth > 0 && $photo.naturalHeight > 0) { - this.applyPhotoTileLayout($tile, $photo); - } else if (!$photo.complete && attempt++ < 20) { - requestAnimationFrame(checkDimensions); - } - }; - requestAnimationFrame(checkDimensions); - } - - private getPhotoTileClass(width: number, height: number): PhotoTileClass { - const imageRatio = width / height; - - return (Object.entries(UiChatMessage.TILE_RATIOS) as Array<[PhotoTileClass, number]>) - .map(([className, tileRatio]) => ({ - className, - distance: Math.abs(Math.log(imageRatio / tileRatio)), - })) - .sort((a, b) => a.distance - b.distance)[0].className; - } - public get id() { return this.config.id; } diff --git a/teamapps-client/ts/modules/UiMasonryGallery.ts b/teamapps-client/ts/modules/UiMasonryGallery.ts new file mode 100644 index 000000000..1944dee41 --- /dev/null +++ b/teamapps-client/ts/modules/UiMasonryGallery.ts @@ -0,0 +1,123 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import { + UiMasonryGallery_ImageClickedEvent, + UiMasonryGalleryCommandHandler, + UiMasonryGalleryConfig, + UiMasonryGalleryEventSource +} from "../generated/UiMasonryGalleryConfig"; +import {UiMasonryGalleryImageConfig} from "../generated/UiMasonryGalleryImageConfig"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; + +type ImageTileClass = "portrait" | "square" | "landscape"; + +export class UiMasonryGallery extends AbstractUiComponent implements UiMasonryGalleryCommandHandler, UiMasonryGalleryEventSource { + + private static readonly TILE_RATIOS: Record = { + portrait: 0.5, + square: 1, + landscape: 2, + }; + + public readonly onImageClicked: TeamAppsEvent = new TeamAppsEvent(); + + private readonly $main: HTMLElement; + + constructor(config: UiMasonryGalleryConfig, context: TeamAppsUiContext) { + super(config, context); + this.$main = document.createElement("div"); + this.$main.classList.add("UiMasonryGallery"); + this.renderImages(config.images); + } + + public doGetMainElement(): HTMLElement { + return this.$main; + } + + public setImages(images: UiMasonryGalleryImageConfig[]): void { + this._config.images = images; + this.renderImages(images); + } + + private renderImages(images: UiMasonryGalleryImageConfig[]): void { + this.$main.replaceChildren(); + this.$main.classList.toggle("image-count-1", images.length === 1); + + images.forEach((image, imageIndex) => { + const $image = document.createElement("img"); + $image.classList.add("image"); + $image.loading = "lazy"; + $image.decoding = "async"; + $image.title = image.fileName ?? ""; + $image.alt = image.fileName ?? ""; + + const $tile = document.createElement("button"); + $tile.type = "button"; + $tile.classList.add("image-tile"); + $tile.addEventListener("click", event => { + event.preventDefault(); + this.onImageClicked.fire({imageIndex}); + }); + $image.addEventListener("load", () => this.applyTileLayout($tile, $image)); + + $tile.appendChild($image); + this.$main.appendChild($tile); + const imageUrl = image.thumbnailUrl ?? image.imageUrl; + $image.src = imageUrl; + $tile.style.setProperty("--tile-image", "url('" + imageUrl + "')"); + this.applyTileLayoutWhenDimensionsAreAvailable($tile, $image); + }); + } + + private applyTileLayout($tile: HTMLElement, $image: HTMLImageElement): void { + if ($image.naturalWidth <= 0 || $image.naturalHeight <= 0) { + return; + } + $tile.classList.remove("portrait", "square", "landscape"); + $tile.classList.add(this.getTileClass($image.naturalWidth, $image.naturalHeight)); + } + + private applyTileLayoutWhenDimensionsAreAvailable($tile: HTMLElement, $image: HTMLImageElement): void { + let attempt = 0; + const checkDimensions = () => { + if ($image.naturalWidth > 0 && $image.naturalHeight > 0) { + this.applyTileLayout($tile, $image); + } else if (!$image.complete && attempt++ < 20) { + requestAnimationFrame(checkDimensions); + } + }; + requestAnimationFrame(checkDimensions); + } + + private getTileClass(width: number, height: number): ImageTileClass { + const imageRatio = width / height; + return (Object.entries(UiMasonryGallery.TILE_RATIOS) as Array<[ImageTileClass, number]>) + .map(([className, tileRatio]) => ({ + className, + distance: Math.abs(Math.log(imageRatio / tileRatio)), + })) + .sort((a, b) => a.distance - b.distance)[0].className; + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiMasonryGallery", UiMasonryGallery); diff --git a/teamapps-client/ts/modules/index.ts b/teamapps-client/ts/modules/index.ts index f1c6d1fc0..a8052bf06 100644 --- a/teamapps-client/ts/modules/index.ts +++ b/teamapps-client/ts/modules/index.ts @@ -86,6 +86,7 @@ export {UiIFrame} from "./UiIFrame"; export {UiFlexContainer} from "./UiFlexContainer"; export {UiChatDisplay} from "./UiChatDisplay"; export {UiChatInput} from "./UiChatInput"; +export {UiMasonryGallery} from "./UiMasonryGallery"; export {UiAbsoluteLayout} from "./UiAbsoluteLayout"; export {UiFloatingComponent} from "./UiFloatingComponent"; export {UiPopup} from "./UiPopup"; diff --git a/teamapps-ui-api/src/main/dto/UiMasonryGallery.dto b/teamapps-ui-api/src/main/dto/UiMasonryGallery.dto new file mode 100644 index 000000000..0e93883c5 --- /dev/null +++ b/teamapps-ui-api/src/main/dto/UiMasonryGallery.dto @@ -0,0 +1,32 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +class UiMasonryGallery extends UiComponent { + required List images; + + command setImages(List images); + + event imageClicked(int imageIndex); +} + +class UiMasonryGalleryImage { + String fileName; + String thumbnailUrl; + required String imageUrl; +} diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGallery.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGallery.java new file mode 100644 index 000000000..7f5928f33 --- /dev/null +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGallery.java @@ -0,0 +1,77 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.teamapps.ux.component.masonrygallery; + +import org.teamapps.dto.UiEvent; +import org.teamapps.dto.UiMasonryGallery; +import org.teamapps.dto.UiMasonryGalleryImage; +import org.teamapps.event.Event; +import org.teamapps.ux.component.AbstractComponent; + +import java.util.List; + +public class MasonryGallery extends AbstractComponent { + + public final Event onImageClicked = new Event<>(); + + private List images; + + public MasonryGallery(List images) { + this.images = copyImages(images); + } + + @Override + public UiMasonryGallery createUiComponent() { + UiMasonryGallery uiMasonryGallery = new UiMasonryGallery(createUiImages()); + mapAbstractUiComponentProperties(uiMasonryGallery); + return uiMasonryGallery; + } + + @Override + public void handleUiEvent(UiEvent event) { + switch (event.getUiEventType()) { + case UI_MASONRY_GALLERY_IMAGE_CLICKED: + UiMasonryGallery.ImageClickedEvent clickedEvent = (UiMasonryGallery.ImageClickedEvent) event; + onImageClicked.fire(clickedEvent.getImageIndex()); + break; + } + } + + public List getImages() { + return images; + } + + public void setImages(List images) { + this.images = copyImages(images); + queueCommandIfRendered(() -> new UiMasonryGallery.SetImagesCommand(getId(), createUiImages())); + } + + private List createUiImages() { + return images.stream().map(image -> new UiMasonryGalleryImage() + .setFileName(image.getFileName()) + .setThumbnailUrl(image.getThumbnail() != null ? image.getThumbnail().getUrl(getSessionContext()) : null) + .setImageUrl(image.getImage() != null ? image.getImage().getUrl(getSessionContext()) : null) + ).toList(); + } + + private static List copyImages(List images) { + return images != null ? List.copyOf(images) : List.of(); + } +} diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGalleryImage.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGalleryImage.java new file mode 100644 index 000000000..2849fc5e6 --- /dev/null +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGalleryImage.java @@ -0,0 +1,53 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.teamapps.ux.component.masonrygallery; + +import org.teamapps.ux.resolvable.Resolvable; + +import java.util.Objects; + +public class MasonryGalleryImage { + + private final String fileName; + private final Resolvable thumbnail; + private final Resolvable image; + + public MasonryGalleryImage(String fileName, Resolvable image) { + this(fileName, null, image); + } + + public MasonryGalleryImage(String fileName, Resolvable thumbnail, Resolvable image) { + this.fileName = fileName; + this.thumbnail = thumbnail; + this.image = Objects.requireNonNull(image, "image"); + } + + public String getFileName() { + return fileName; + } + + public Resolvable getThumbnail() { + return thumbnail; + } + + public Resolvable getImage() { + return image; + } +} From af0661b406a75dbea59dc4f42575105ee39d408c Mon Sep 17 00:00:00 2001 From: Hannes Drittler Date: Thu, 25 Jun 2026 19:59:58 +0200 Subject: [PATCH 4/5] fix build --- teamapps-client/package.json | 2 +- .../teamapps/ux/component/masonrygallery/MasonryGallery.java | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/teamapps-client/package.json b/teamapps-client/package.json index 91f85bf89..9b42f1fad 100644 --- a/teamapps-client/package.json +++ b/teamapps-client/package.json @@ -1,6 +1,6 @@ { "name": "teamapps-client", - "version": "0.9.208-SNAPSHOT", + "version": "0.9.209-SNAPSHOT", "description": "teamapps", "author": "TeamApps.org", "main": "dist/js/main.js", diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGallery.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGallery.java index 7f5928f33..630a423ff 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGallery.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/masonrygallery/MasonryGallery.java @@ -64,10 +64,9 @@ public void setImages(List images) { } private List createUiImages() { - return images.stream().map(image -> new UiMasonryGalleryImage() + return images.stream().map(image -> new UiMasonryGalleryImage(image.getImage().getUrl(getSessionContext())) .setFileName(image.getFileName()) .setThumbnailUrl(image.getThumbnail() != null ? image.getThumbnail().getUrl(getSessionContext()) : null) - .setImageUrl(image.getImage() != null ? image.getImage().getUrl(getSessionContext()) : null) ).toList(); } From ef3538a0e0dc3dbb834433b70109ebd5d4d60f88 Mon Sep 17 00:00:00 2001 From: Hannes Drittler Date: Fri, 26 Jun 2026 17:39:22 +0200 Subject: [PATCH 5/5] fix responsiveness for masonry on small screens --- .../less/components/UiMasonryGallery.less | 28 +++++++++++++------ .../ts/modules/UiMasonryGallery.ts | 8 ++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/teamapps-client/less/components/UiMasonryGallery.less b/teamapps-client/less/components/UiMasonryGallery.less index 2c6b81ad6..b7995ee3e 100644 --- a/teamapps-client/less/components/UiMasonryGallery.less +++ b/teamapps-client/less/components/UiMasonryGallery.less @@ -18,18 +18,26 @@ * =========================LICENSE_END================================== */ .UiMasonryGallery { - --image-cell-size: min(174px, calc((100% - 4px) / 2)); - - display: grid; - grid-template-columns: repeat(auto-fill, var(--image-cell-size)); - grid-auto-rows: var(--image-cell-size); - grid-auto-flow: dense; - justify-content: start; - gap: 4px; + container-type: inline-size; min-width: 0; width: 100%; box-sizing: border-box; + .image-grid { + --image-cell-size: ~"min(174px, calc((100cqw - 4px) / 2))"; + + display: grid; + grid-template-columns: repeat(auto-fill, var(--image-cell-size)); + grid-auto-rows: var(--image-cell-size); + grid-auto-flow: dense; + justify-content: start; + align-content: start; + gap: 4px; + min-width: 0; + width: 100%; + box-sizing: border-box; + } + .image-tile { position: relative; display: block; @@ -88,6 +96,10 @@ &.image-count-1 { display: block; + .image-grid { + display: block; + } + .image-tile { display: block; width: 350px; diff --git a/teamapps-client/ts/modules/UiMasonryGallery.ts b/teamapps-client/ts/modules/UiMasonryGallery.ts index 1944dee41..dd4a91c12 100644 --- a/teamapps-client/ts/modules/UiMasonryGallery.ts +++ b/teamapps-client/ts/modules/UiMasonryGallery.ts @@ -42,11 +42,15 @@ export class UiMasonryGallery extends AbstractUiComponent = new TeamAppsEvent(); private readonly $main: HTMLElement; + private readonly $grid: HTMLElement; constructor(config: UiMasonryGalleryConfig, context: TeamAppsUiContext) { super(config, context); this.$main = document.createElement("div"); this.$main.classList.add("UiMasonryGallery"); + this.$grid = document.createElement("div"); + this.$grid.classList.add("image-grid"); + this.$main.appendChild(this.$grid); this.renderImages(config.images); } @@ -60,7 +64,7 @@ export class UiMasonryGallery extends AbstractUiComponent { @@ -81,7 +85,7 @@ export class UiMasonryGallery extends AbstractUiComponent this.applyTileLayout($tile, $image)); $tile.appendChild($image); - this.$main.appendChild($tile); + this.$grid.appendChild($tile); const imageUrl = image.thumbnailUrl ?? image.imageUrl; $image.src = imageUrl; $tile.style.setProperty("--tile-image", "url('" + imageUrl + "')");