Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>org.vaadin.addons.flowingcode</groupId>
<artifactId>chat-assistant-addon</artifactId>
<version>5.1.1-SNAPSHOT</version>
<version>5.2.0-SNAPSHOT</version>
<name>Chat Assistant Add-on</name>
<description>Chat Assistant Add-on for Vaadin Flow</description>
<url>https://www.flowingcode.com/en/open-source/</url>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Base64;
Expand Down Expand Up @@ -130,6 +131,7 @@
private static final String DEFAULT_FAB_CLASS = "fc-chat-assistant-fab";
private static final String DEFAULT_RESIZE_CLASS = "fc-chat-assistant-resize";
private static final String DEFAULT_UNREAD_BADGE_CLASS = "fc-chat-assistant-unread-badge";
private static final String DEFAULT_MESSAGE_INPUT_CLASS = "fc-chat-assistant-message-input";
private static final String RESIZE_INDICATOR_VISIBLE_CLASS = "fc-chat-assistant-resize-indicator-visible";
// Theme-agnostic token fallback chains (Lumo token, then Aura token, then a literal) so the badge
// renders correctly under both the Lumo and Aura themes of Vaadin 25.
Expand Down Expand Up @@ -163,6 +165,7 @@
private Component headerComponent;
private Component footerContainer;
private FabPosition fabPosition = DEFAULT_POSITION;
private Duration fabPositionMemory = Duration.ZERO;
private FabVariant activeSizeVariant = null;
private Span whoIsTyping;
private Registration defaultSubmitListenerRegistration;
Expand Down Expand Up @@ -206,6 +209,7 @@
null,
null,
null,
null,
null);
}

Expand Down Expand Up @@ -259,6 +263,8 @@
* the default)
* @param maxWidth the chat window maximum width ({@code null} keeps the default)
* @param maxHeight the chat window maximum height ({@code null} keeps the default)
* @param fabPositionMemory how long the FAB keeps a dragged position after the user leaves the
* page ({@code null} keeps it off, see {@link #setFabPositionMemory(Duration)})
*/
@Builder
private ChatAssistant(
Expand All @@ -277,7 +283,8 @@
String width,
String height,
String maxWidth,
String maxHeight) {
String maxHeight,
final Duration fabPositionMemory) {
if (messages != null) {
this.messages.addAll(messages);
}
Expand All @@ -300,6 +307,9 @@
this.initializeFooter();
this.initializeContent(markdownEnabled);
this.initializeChatWindow();
if (fabPositionMemory != null) {
setFabPositionMemory(fabPositionMemory);
}
}

/** Seeds the builder defaults for the flags whose real default is not {@code false}. */
Expand Down Expand Up @@ -936,8 +946,9 @@
fab.getElement().removeAttribute("anchored");
}
// An anchored FAB is lifted to document.body so its position:fixed clears any ancestor
// containing block (a transformed/filtered container traps a fixed descendant otherwise);
// un-anchoring returns it to its container. Idempotent with the portal done at movement init.
// containing block (a transformed/filtered container traps a fixed descendant otherwise),
// except inside a shadow root, where it stays to keep its scoped styles; un-anchoring returns
// it to its container. Idempotent with the portal done at movement init.
fabWrapper
.getElement()
.executeJs(
Expand Down Expand Up @@ -967,6 +978,53 @@
resetFabPosition();
}

/**
* Makes the FAB keep the position the user dragged it to across page loads, as long as the user
* comes back within the given time after leaving the page. The position is kept per browser tab,
* in its session storage, as a proportion of the room the FAB can move in, so it survives a
* different window size. Only a movable FAB anchored to the viewport restores it.
* {@link #resetFabPosition()}, {@link #setFabPosition(FabPosition)} and switching between
* desktop and mobile mode forget it once the FAB is on the page; configuring the corner or the
* mode before the assistant is attached does not, so a view can set them up on every load.
*
* <p>Assistants on the same origin share the saved position unless they have different ids, so
* set the id before the assistant is attached. The timeout has millisecond precision.
* {@link Duration#ZERO}, the default, turns the memory off; a position saved earlier is then
* ignored.
*
* @since 5.2.0
* @param timeout how long the position outlives the page; not {@code null}, not negative and,
* unless zero, at least one millisecond
* @throws IllegalArgumentException if the timeout is negative or under one millisecond
* @throws ArithmeticException if the timeout does not fit in a {@code long} of milliseconds
*/
public void setFabPositionMemory(final Duration timeout) {
Objects.requireNonNull(timeout, "Timeout cannot be null");
if (timeout.isNegative()) {
throw new IllegalArgumentException("Timeout cannot be negative");
}
final long millis = timeout.toMillis();
if (millis == 0 && !timeout.isZero()) {
throw new IllegalArgumentException("Timeout cannot be under one millisecond");
}
this.fabPositionMemory = timeout;
if (millis == 0) {
fab.getElement().removeAttribute("position-memory");
} else {
fab.getElement().setAttribute("position-memory", String.valueOf(millis));
}
}

/**
* Returns how long the FAB keeps a dragged position after the user leaves the page.
*
* @since 5.2.0
* @see #setFabPositionMemory(Duration)
*/
public Duration getFabPositionMemory() {
return fabPositionMemory;
}

/**
* Returns the FAB's configured corner.
*
Expand Down Expand Up @@ -1155,11 +1213,8 @@
@SuppressWarnings("unchecked")
protected void initializeFooter() {
this.messageInput = new MessageInput();
this.messageInput
.getStyle()
.setMaxHeight("80px")
.set("width", "100%")
.setPadding("0 2px"); // Account for border when focused (it will get cropped otherwise)
this.messageInput.addClassName(DEFAULT_MESSAGE_INPUT_CLASS);
this.messageInput.getStyle().set("width", "100%");

this.defaultSubmitListenerRegistration =
this.messageInput.addSubmitListener(
Expand All @@ -1167,7 +1222,7 @@
this.sendMessage(
(T)
Message.builder()
.messageTime(LocalDateTime.now())

Check warning on line 1225 in src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Explicitly specify the time zone by passing a ZoneId or a Clock to the .now() method.

See more on https://sonarcloud.io/project/issues?id=FlowingCode_ChatAssistant&issues=AaEb7Q4Bl9jCT5nsu7Uu&open=AaEb7Q4Bl9jCT5nsu7Uu&pullRequest=73
.name("User")
.content(se.getValue())
.build()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ if (!customElements.get('animated-fab')) {
// Lifts the FAB wrapper to <body> while it is anchored to the viewport, so its position:fixed
// resolves against the viewport rather than an ancestor containing block. Any ancestor with
// transform/filter/backdrop-filter/perspective/contain/will-change (e.g. Aura's AppLayout navbar)
// would otherwise trap the wrapper. When it is not anchored, the wrapper is returned to its home
// slot so position:absolute stays relative to its container. Idempotent.
// would otherwise trap the wrapper. Inside a shadow root the wrapper stays in place, as the portal
// would strip its scoped styles, so such an ancestor can still trap it there. When it is not
// anchored, the wrapper is returned to its home slot so position:absolute stays relative to its
// container. Idempotent.
window.fcChatAssistantPortalFab = (item, anchored) => {
if (anchored) {
if (anchored && (item.__fcHome ? item.__fcHome.parent : item.parentNode)?.getRootNode() === document) {
if (item.parentNode !== document.body) {
// Remember where the wrapper lived so it can be put back on teardown / un-anchor.
item.__fcHome = item.__fcHome || { parent: item.parentNode, next: item.nextSibling };
Expand Down Expand Up @@ -115,6 +117,76 @@ function fcChatAssistantCornerPosition(item, fab, corner, margin) {
}
}

function fcChatAssistantClamp01(value) {
return Math.min(1, Math.max(0, Number(value) || 0));
}

// Remembers where the user dragged the FAB, in the tab's session storage, while the FAB has a
// position-memory timeout in milliseconds. Refreshing it on pagehide makes the timeout measure the
// time away from the page.
function fcChatAssistantPositionMemory(root, fab) {
// FABs on the same origin share the entry unless their hosts have an id
const key = 'fc-chat-assistant-fab-position' + (root.id ? ':' + root.id : '');
const timeout = () => Number.parseFloat(fab.getAttribute('position-memory')) || 0;
// Storage may be blocked or full; that must never interrupt the FAB
const attempt = (action) => {
try {
return action(window.sessionStorage);
} catch (e) {
return null;
}
};
// Whether this page saved or restored the entry; only then does leaving the page refresh it
let held = false;
const forget = () => {
held = false;
attempt(storage => storage.removeItem(key));
};
const isValid = (entry) => entry !== null && typeof entry === 'object'
&& Number.isFinite(entry.x) && Number.isFinite(entry.y)
&& Number.isFinite(entry.at) && entry.at <= Date.now();
const readValid = () => {
const entry = attempt(storage => JSON.parse(storage.getItem(key)));
return isValid(entry) ? entry : null;
};
// The entry if it is well formed and within the timeout; anything else is dropped
const read = () => {
const entry = readValid();
if (entry && Date.now() - entry.at <= timeout()) {
return entry;
}
forget();
return null;
};
const write = (entry) => attempt(storage => storage.setItem(key, JSON.stringify(entry)));
return {
save(ratio) {
if (timeout() > 0) {
write({ x: ratio.x, y: ratio.y, at: Date.now() });
held = true;
}
},
// The timeout runs from leaving the page, so a long stay must not expire the entry here
touch() {
const entry = timeout() > 0 && held ? readValid() : null;
if (entry) {
write({ ...entry, at: Date.now() });
Comment thread
paodb marked this conversation as resolved.
}
},
restore(ratio) {
const entry = timeout() > 0 ? read() : null;
if (!entry) {
return false;
}
ratio.x = fcChatAssistantClamp01(entry.x);
ratio.y = fcChatAssistantClamp01(entry.y);
held = true;
return true;
},
forget,
};
}

window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, positionRaw) => {
// Prevent duplicate initialization
const guard = `__fcChatAssistantMovement`;
Expand All @@ -128,8 +200,10 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po
const snapTransition = 'all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)';
const position = { x: margin, y: margin };
const initialPosition = { x: margin, y: margin };
// Expose the live position so the reset hook can move the FAB after initialization.
item.__fcPosition = position;
// Ratios run from 0 at the right/bottom edge to 1 at the left/top one, so a resize keeps the
// FAB's place
const ratio = { x: 0, y: 0 };
const memory = fcChatAssistantPositionMemory(root, fab);

let screenWidth = window.innerWidth;
let screenHeight = window.innerHeight;
Expand All @@ -143,12 +217,20 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po

// The popover content part clamps itself to the viewport (max-height/width: 100%), so no
// manual container shrinking is needed here. Just keep the FAB within the new screen bounds.
snapToBoundary();
// Only an anchored FAB is placed against the viewport; one in a container keeps its offsets.
if (fab.hasAttribute('anchored')) {
applyRatio();
} else {
snapToBoundary();
}
};
window.addEventListener("resize", resizeHandler);
const pageHideHandler = () => memory.touch();
window.addEventListener("pagehide", pageHideHandler);

(root.__fcCleanups = root.__fcCleanups || []).push(() => {
window.removeEventListener("resize", resizeHandler);
window.removeEventListener("pagehide", pageHideHandler);
window.fcChatAssistantMobileModeOff?.(root);
window.fcChatAssistantScreenSizeOffAll?.(root);
// Put the wrapper back in its home slot so it is removed with the detached host, not left
Expand All @@ -159,7 +241,8 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po
root['fc-chat-assistant-drag-listener'] = null;
});

// Escape any ancestor containing block by portaling the wrapper to <body> while anchored.
// Escape any ancestor containing block by portaling the wrapper to <body> while anchored,
// outside a shadow root.
window.fcChatAssistantPortalFab(item, fab.hasAttribute('anchored'));

// Update FAB position
Expand All @@ -168,6 +251,36 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po
item.style.bottom = position.y + 'px';
}

function room() {
const size = fcChatAssistantSize(fab);
return {
x: Math.max(0, screenWidth - size.width - 2 * margin),
y: Math.max(0, screenHeight - size.height - 2 * margin),
};
}

function syncRatio() {
const available = room();
ratio.x = available.x > 0 ? fcChatAssistantClamp01((position.x - margin) / available.x) : 0;
ratio.y = available.y > 0 ? fcChatAssistantClamp01((position.y - margin) / available.y) : 0;
}

function applyRatio() {
const available = room();
position.x = margin + ratio.x * available.x;
position.y = margin + ratio.y * available.y;
updatePosition();
}

item.__fcMovement = {
moveTo(x, y) {
position.x = x;
position.y = y;
syncRatio();
memory.forget();
},
};

// Ensure the item stays within the screen and margin bounds
function snapToBoundary() {
// Get current dimensions to account for transforms
Expand Down Expand Up @@ -234,6 +347,9 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po
snapToBoundary();
if (isClickOnlyEvent()) {
root.$server?.onClick();
} else {
syncRatio();
memory.save(ratio);
}
}
}
Expand All @@ -252,8 +368,12 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po
const start = fcChatAssistantCornerPosition(item, fab, positionRaw, margin);
position.x = start.x;
position.y = start.y;
initialPosition.x = start.x;
initialPosition.y = start.y;
syncRatio();
if (fab.hasAttribute('movable') && fab.hasAttribute('anchored') && memory.restore(ratio)) {
applyRatio();
}
initialPosition.x = position.x;
initialPosition.y = position.y;
updatePosition();
}
if (fcChatAssistantSize(fab).width > 0) {
Expand Down Expand Up @@ -286,11 +406,10 @@ window.fcChatAssistantResetPosition = (item, marginRaw, positionRaw) => {
item.style.transition = resetTransition;
item.style.right = target.x + 'px';
item.style.bottom = target.y + 'px';
// Keep the live drag state in sync so the next drag starts from the reset position.
if (item.__fcPosition) {
item.__fcPosition.x = target.x;
item.__fcPosition.y = target.y;
}
// Keep the live drag state in sync so the next drag starts from the reset position. Before the
// movement is initialized the saved position is kept on purpose: a corner configured while the
// view is built must not discard it on every page load.
item.__fcMovement?.moveTo(target.x, target.y);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

// Removes any active media-query listener, freezing the component in its current mode. Also clears
Expand Down
Loading
Loading