- Gewijzigde logica voor hoogtebepaling chat-input en message history, zodat ook de mobiele client correct functioneert.
442 lines
14 KiB
Vue
442 lines
14 KiB
Vue
<template>
|
|
<div class="message-history-container">
|
|
<!-- Normal messages container -->
|
|
<div class="chat-messages" ref="messagesContainer">
|
|
<!-- Loading indicator for load more -->
|
|
<div v-if="$slots.loading" class="load-more-indicator">
|
|
<slot name="loading"></slot>
|
|
</div>
|
|
|
|
<!-- Messages wrapper for bottom alignment -->
|
|
<div class="messages-wrapper">
|
|
<!-- Normal message list (excluding temporarily positioned AI messages) -->
|
|
<template v-if="normalMessages.length > 0">
|
|
<!-- Messages -->
|
|
<template v-for="(message, index) in normalMessages" :key="message.id">
|
|
<!-- The actual message -->
|
|
<chat-message
|
|
:message="message"
|
|
:is-submitting-form="isSubmittingForm"
|
|
:api-prefix="apiPrefix"
|
|
:is-latest-ai-message="isLatestAiMessage(message)"
|
|
@image-loaded="handleImageLoaded"
|
|
@specialist-complete="$emit('specialist-complete', $event)"
|
|
@specialist-error="$emit('specialist-error', $event)"
|
|
></chat-message>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Typing indicator -->
|
|
<typing-indicator v-if="isTyping"></typing-indicator>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import ChatMessage from './ChatMessage.vue';
|
|
import TypingIndicator from './TypingIndicator.vue';
|
|
import { useTranslationClient } from '../js/composables/useTranslation.js';
|
|
|
|
export default {
|
|
name: 'MessageHistory',
|
|
components: {
|
|
'chat-message': ChatMessage,
|
|
'typing-indicator': TypingIndicator
|
|
},
|
|
setup() {
|
|
const { translateSafe } = useTranslationClient();
|
|
|
|
return {
|
|
translateSafe
|
|
};
|
|
},
|
|
props: {
|
|
messages: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
isTyping: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
isSubmittingForm: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
apiPrefix: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
autoScroll: {
|
|
type: Boolean,
|
|
default: true
|
|
}
|
|
},
|
|
emits: ['load-more', 'specialist-complete', 'specialist-error'],
|
|
data() {
|
|
return {
|
|
isAtBottom: true,
|
|
showScrollButton: false,
|
|
unreadCount: 0,
|
|
languageChangeHandler: null,
|
|
_prevSnapshot: { length: 0, firstId: null, lastId: null },
|
|
};
|
|
},
|
|
computed: {
|
|
// Messages that should be shown in normal flow (excluding temporarily positioned AI messages)
|
|
normalMessages() {
|
|
return this.messages.filter(msg => !msg.isTemporarilyAtBottom);
|
|
},
|
|
},
|
|
watch: {
|
|
messages: {
|
|
async handler(newMessages, oldMessages) {
|
|
const prev = this._prevSnapshot || { length: 0, firstId: null, lastId: null };
|
|
const curr = this.makeSnapshot(newMessages);
|
|
const container = this.$refs.messagesContainer;
|
|
|
|
const lengthIncreased = curr.length > prev.length;
|
|
const lengthDecreased = curr.length < prev.length; // reset/trim
|
|
const appended = lengthIncreased && curr.lastId !== prev.lastId;
|
|
const prepended = lengthIncreased && curr.firstId !== prev.firstId && curr.lastId === prev.lastId;
|
|
const mutatedLastSameLength = curr.length === prev.length && curr.lastId === prev.lastId;
|
|
|
|
if (prepended && container) {
|
|
// Load-more bovenaan: positie behouden
|
|
const before = container.scrollHeight;
|
|
await this.nextFrame();
|
|
await this.nextFrame();
|
|
this.preserveScrollOnPrepend(before);
|
|
} else if (appended) {
|
|
// Nieuw bericht onderaan: altijd naar beneden (eis)
|
|
await this.scrollToBottom(true, { smooth: true, retries: 2 });
|
|
} else if (mutatedLastSameLength) {
|
|
// Laatste bericht groeit (AI streaming/media). Alleen sticky als we al onderaan waren of autoScroll actief is
|
|
if (this.autoScroll || this.isAtBottom) {
|
|
await this.scrollToBottom(false, { smooth: true, retries: 2 });
|
|
}
|
|
} else if (lengthDecreased && this.autoScroll) {
|
|
// Lijst verkort (reset): terug naar onderen
|
|
await this.scrollToBottom(true, { smooth: false, retries: 2 });
|
|
}
|
|
|
|
this._prevSnapshot = curr;
|
|
},
|
|
deep: true,
|
|
immediate: false,
|
|
},
|
|
},
|
|
created() {
|
|
// Maak een benoemde handler voor betere cleanup
|
|
this.languageChangeHandler = (event) => {
|
|
if (event.detail && event.detail.language) {
|
|
this.handleLanguageChange(event);
|
|
}
|
|
};
|
|
|
|
// Luister naar taalwijzigingen
|
|
document.addEventListener('language-changed', this.languageChangeHandler);
|
|
},
|
|
mounted() {
|
|
this.setupScrollListener();
|
|
|
|
if (this.autoScroll) {
|
|
this.$nextTick(() => this.scrollToBottom(true, { smooth: false, retries: 2 }));
|
|
}
|
|
|
|
this._onResize = this.debounce(() => {
|
|
if (this.autoScroll || this.isAtBottom) {
|
|
this.scrollToBottom(false, { smooth: true, retries: 1 });
|
|
}
|
|
}, 150);
|
|
window.addEventListener('resize', this._onResize);
|
|
|
|
const wrapper = this.$el.querySelector('.messages-wrapper');
|
|
if (window.ResizeObserver && wrapper) {
|
|
this._resizeObserver = new ResizeObserver(() => {
|
|
if (this.autoScroll || this.isAtBottom) {
|
|
this.scrollToBottom(false, { smooth: true, retries: 1 });
|
|
}
|
|
});
|
|
this._resizeObserver.observe(wrapper);
|
|
}
|
|
},
|
|
beforeUnmount() {
|
|
const container = this.$refs.messagesContainer;
|
|
if (container) container.removeEventListener('scroll', this.handleScroll);
|
|
if (this.languageChangeHandler) {
|
|
document.removeEventListener('language-changed', this.languageChangeHandler);
|
|
}
|
|
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
|
if (this._resizeObserver) this._resizeObserver.disconnect();
|
|
},
|
|
methods: {
|
|
async handleLanguageChange(event) {
|
|
// Controleer of dit het eerste bericht is in een gesprek met maar één bericht
|
|
if (this.messages.length === 1 && this.messages[0].sender === 'ai') {
|
|
const firstMessage = this.messages[0];
|
|
|
|
// Controleer of we een originele inhoud hebben om te vertalen
|
|
if (firstMessage.content) {
|
|
|
|
if (!firstMessage.originalContent) {
|
|
firstMessage.originalContent = firstMessage.content;
|
|
}
|
|
|
|
console.log('Originele inhoud van eerste AI bericht:', firstMessage.originalContent);
|
|
console.log('Content eerste AI bericht:', firstMessage.content);
|
|
|
|
console.log('Vertaling van eerste AI bericht naar:', event.detail.language);
|
|
|
|
try {
|
|
// Gebruik moderne translateSafe composable
|
|
const translatedText = await this.translateSafe(
|
|
firstMessage.originalContent,
|
|
event.detail.language,
|
|
{
|
|
context: 'chat_message',
|
|
apiPrefix: this.apiPrefix,
|
|
fallbackText: firstMessage.originalContent
|
|
}
|
|
);
|
|
|
|
// Update de inhoud van het bericht
|
|
firstMessage.content = translatedText;
|
|
console.log('Eerste bericht succesvol vertaald');
|
|
} catch (error) {
|
|
console.error('Fout bij vertalen eerste bericht:', error);
|
|
// Fallback naar originele inhoud
|
|
firstMessage.content = firstMessage.originalContent;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
async nextFrame() {
|
|
await this.$nextTick();
|
|
await new Promise(r => requestAnimationFrame(r));
|
|
},
|
|
|
|
async scrollToBottom(force = false, { smooth = true, retries = 2 } = {}) {
|
|
const container = this.$refs.messagesContainer;
|
|
if (!container) return;
|
|
|
|
const doScroll = (instant = false) => {
|
|
const behavior = instant ? 'auto' : (smooth ? 'smooth' : 'auto');
|
|
container.scrollTo({ top: container.scrollHeight, behavior });
|
|
};
|
|
|
|
// Wacht enkele frames om late layout/afbeeldingen te vangen
|
|
for (let i = 0; i < retries; i++) {
|
|
await this.nextFrame();
|
|
}
|
|
|
|
// Probeer smooth naar onder
|
|
doScroll(false);
|
|
|
|
// Forceer desnoods na nog een frame een harde correctie
|
|
if (force) {
|
|
await this.nextFrame();
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
this.isAtBottom = true;
|
|
this.showScrollButton = false;
|
|
this.unreadCount = 0;
|
|
},
|
|
|
|
makeSnapshot(list) {
|
|
const length = list.length;
|
|
const firstId = length ? list[0].id : null;
|
|
const lastId = length ? list[length - 1].id : null;
|
|
return { length, firstId, lastId };
|
|
},
|
|
|
|
preserveScrollOnPrepend(beforeHeight) {
|
|
const container = this.$refs.messagesContainer;
|
|
if (!container) return;
|
|
const afterHeight = container.scrollHeight;
|
|
const delta = afterHeight - beforeHeight;
|
|
container.scrollTop = container.scrollTop + delta;
|
|
},
|
|
|
|
setupScrollListener() {
|
|
const container = this.$refs.messagesContainer;
|
|
if (!container) return;
|
|
|
|
container.addEventListener('scroll', this.handleScroll);
|
|
},
|
|
|
|
debounce(fn, wait = 150) {
|
|
let t;
|
|
return (...args) => {
|
|
clearTimeout(t);
|
|
t = setTimeout(() => fn.apply(this, args), wait);
|
|
};
|
|
},
|
|
|
|
handleScroll() {
|
|
const container = this.$refs.messagesContainer;
|
|
if (!container) return;
|
|
const threshold = 80; // was 50
|
|
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
|
this.isAtBottom = isNearBottom;
|
|
if (container.scrollTop === 0) this.$emit('load-more');
|
|
},
|
|
|
|
handleImageLoaded() {
|
|
// Auto-scroll when img load to maintain position
|
|
if (this.isAtBottom) {
|
|
this.$nextTick(() => this.scrollToBottom());
|
|
}
|
|
},
|
|
|
|
searchMessages(query) {
|
|
// Simple message search
|
|
if (!query.trim()) return this.messages;
|
|
|
|
const searchTerm = query.toLowerCase();
|
|
return this.messages.filter(message =>
|
|
message.content &&
|
|
message.content.toLowerCase().includes(searchTerm)
|
|
);
|
|
},
|
|
|
|
isLatestAiMessage(message) {
|
|
// Only AI messages with taskId can be "latest"
|
|
if (message.sender !== 'ai' || !message.taskId) {
|
|
return false;
|
|
}
|
|
|
|
// Find the latest AI message with a taskId by iterating in reverse order
|
|
// The latest AI message is the one where the specialist interaction is still active
|
|
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
const msg = this.messages[i];
|
|
if (msg.sender === 'ai' && msg.taskId) {
|
|
// This is the latest AI message with a taskId
|
|
return msg.id === message.id;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.message-history-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
/* height: 100%; avoid forcing parent height */
|
|
min-height: 0; /* Laat kinderen scrollen */
|
|
padding: 16px; /* iets minder padding om ruimte te besparen */
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
max-width: 1000px;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
/* overflow: hidden; // mag weg of blijven; met stap 1 clipt dit niet meer */
|
|
}
|
|
|
|
.chat-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
|
|
scrollbar-gutter: stable both-edges; /* houdt ruimte vrij voor scrollbar */
|
|
padding-right: 0; /* haal de hack weg */
|
|
margin-right: 0;
|
|
|
|
/* Belangrijk: haal min-height: 100% weg en vervang */
|
|
/* min-height: 100%; */
|
|
min-height: 0; /* toestaan dat het kind krimpt voor overflow */
|
|
-webkit-overflow-scrolling: touch; /* betere iOS scroll */
|
|
}
|
|
|
|
.messages-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-end;
|
|
min-height: 100%;
|
|
gap: 10px; /* Space between messages */
|
|
}
|
|
|
|
.load-more-indicator {
|
|
text-align: center;
|
|
padding: 10px;
|
|
color: #666;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
text-align: center;
|
|
color: #666;
|
|
padding: 40px 20px;
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 16px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.empty-text {
|
|
font-size: 1.2rem;
|
|
font-weight: 500;
|
|
margin-bottom: 8px;
|
|
color: #333;
|
|
}
|
|
|
|
.empty-subtext {
|
|
font-size: 0.9rem;
|
|
color: #666;
|
|
max-width: 300px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* Scrollbar styling */
|
|
.chat-messages::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.chat-messages::-webkit-scrollbar-track {
|
|
background: #f1f1f1;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.chat-messages::-webkit-scrollbar-thumb {
|
|
background: #c1c1c1;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.chat-messages::-webkit-scrollbar-thumb:hover {
|
|
background: #a8a8a8;
|
|
}
|
|
|
|
/* Responsive adjustments */
|
|
@media (max-width: 768px) {
|
|
.chat-messages {
|
|
padding: 8px;
|
|
}
|
|
|
|
.empty-state {
|
|
padding: 20px 16px;
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 2.5rem;
|
|
}
|
|
|
|
.empty-text {
|
|
font-size: 1.1rem;
|
|
}
|
|
}
|
|
</style> |