571 lines
17 KiB
Vue
571 lines
17 KiB
Vue
<script setup>
|
|
const toast = useToast()
|
|
const { $api } = useNuxtApp()
|
|
|
|
const status = ref(null)
|
|
const identity = ref(null)
|
|
const generalRoom = ref(null)
|
|
const matrixMessages = ref([])
|
|
const matrixMembers = ref([])
|
|
const matrixMessageDraft = ref("")
|
|
const matrixMessagesViewport = ref(null)
|
|
const loading = ref(false)
|
|
const roomProvisioning = ref(false)
|
|
const matrixMessagesLoading = ref(false)
|
|
const matrixMembersLoading = ref(false)
|
|
const matrixMessageSending = ref(false)
|
|
const matrixAutoRefreshActive = ref(false)
|
|
const lastUpdated = ref(null)
|
|
let matrixRefreshInterval = null
|
|
let matrixMessagesRequestActive = false
|
|
let matrixMembersRequestActive = false
|
|
|
|
const canUseMatrixChat = computed(() =>
|
|
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
|
|
)
|
|
|
|
const activeRoom = computed(() => ({
|
|
key: "general",
|
|
name: generalRoom.value?.name || "Allgemeiner Chat",
|
|
description: generalRoom.value?.alias || "Mandantenweiter Austausch",
|
|
exists: Boolean(generalRoom.value?.exists),
|
|
roomId: generalRoom.value?.roomId || "",
|
|
unread: 0
|
|
}))
|
|
|
|
const rooms = computed(() => [
|
|
activeRoom.value,
|
|
{
|
|
key: "projects",
|
|
name: "Projekt-Chats",
|
|
description: "Nächste Ausbaustufe",
|
|
exists: false,
|
|
disabled: true
|
|
},
|
|
{
|
|
key: "direct",
|
|
name: "Direktnachrichten",
|
|
description: "Nächste Ausbaustufe",
|
|
exists: false,
|
|
disabled: true
|
|
}
|
|
])
|
|
|
|
const mergeMatrixMessages = (incomingMessages) => {
|
|
const byId = new Map()
|
|
|
|
for (const message of matrixMessages.value) {
|
|
byId.set(message.id, message)
|
|
}
|
|
|
|
for (const message of incomingMessages || []) {
|
|
byId.set(message.id, {
|
|
...byId.get(message.id),
|
|
...message,
|
|
pending: false,
|
|
failed: false
|
|
})
|
|
}
|
|
|
|
matrixMessages.value = Array.from(byId.values())
|
|
.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0))
|
|
}
|
|
|
|
const scrollMessagesToBottom = async () => {
|
|
await nextTick()
|
|
if (!matrixMessagesViewport.value) return
|
|
|
|
matrixMessagesViewport.value.scrollTop = matrixMessagesViewport.value.scrollHeight
|
|
}
|
|
|
|
const loadChatInfo = async () => {
|
|
loading.value = true
|
|
try {
|
|
const [statusRes, identityRes, roomRes] = await Promise.all([
|
|
$api("/api/communication/matrix/status"),
|
|
$api("/api/communication/matrix/me"),
|
|
$api("/api/communication/matrix/rooms/general")
|
|
])
|
|
|
|
status.value = statusRes
|
|
identity.value = identityRes
|
|
generalRoom.value = roomRes
|
|
lastUpdated.value = new Date()
|
|
|
|
if (roomRes?.exists && canUseMatrixChat.value) {
|
|
await loadGeneralChat({ silent: true, includeMembers: true })
|
|
}
|
|
} catch (error) {
|
|
toast.add({
|
|
title: "Chat konnte nicht geladen werden",
|
|
color: "error"
|
|
})
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const provisionGeneralRoom = async () => {
|
|
roomProvisioning.value = true
|
|
try {
|
|
const res = await $api("/api/communication/matrix/rooms/general/provision", {
|
|
method: "POST"
|
|
})
|
|
|
|
generalRoom.value = {
|
|
tenantId: res.tenantId,
|
|
tenantName: res.tenantName,
|
|
key: res.key,
|
|
name: res.name,
|
|
alias: res.alias,
|
|
exists: true,
|
|
roomId: res.roomId,
|
|
servers: res.servers || []
|
|
}
|
|
|
|
toast.add({
|
|
title: res.alreadyExisted ? "Allgemeiner Chat ist bereit" : "Allgemeiner Chat erstellt",
|
|
color: "success"
|
|
})
|
|
|
|
await loadGeneralChat({ includeMembers: true })
|
|
} catch (error) {
|
|
toast.add({
|
|
title: "Allgemeiner Chat konnte nicht erstellt werden",
|
|
color: "error"
|
|
})
|
|
} finally {
|
|
roomProvisioning.value = false
|
|
}
|
|
}
|
|
|
|
const loadGeneralMessages = async ({ silent = false } = {}) => {
|
|
if (!canUseMatrixChat.value || !generalRoom.value?.exists) return
|
|
if (matrixMessagesRequestActive) return
|
|
|
|
matrixMessagesRequestActive = true
|
|
if (!silent) matrixMessagesLoading.value = true
|
|
|
|
try {
|
|
const res = await $api("/api/communication/matrix/rooms/general/messages")
|
|
mergeMatrixMessages(res.messages || [])
|
|
generalRoom.value = {
|
|
...generalRoom.value,
|
|
alias: res.alias || generalRoom.value?.alias,
|
|
exists: true,
|
|
roomId: res.roomId || generalRoom.value?.roomId
|
|
}
|
|
await scrollMessagesToBottom()
|
|
} catch (error) {
|
|
if (!silent) {
|
|
toast.add({
|
|
title: "Nachrichten konnten nicht geladen werden",
|
|
color: "error"
|
|
})
|
|
}
|
|
} finally {
|
|
matrixMessagesRequestActive = false
|
|
if (!silent) matrixMessagesLoading.value = false
|
|
}
|
|
}
|
|
|
|
const loadGeneralMembers = async ({ silent = false } = {}) => {
|
|
if (!canUseMatrixChat.value || !generalRoom.value?.exists) return
|
|
if (matrixMembersRequestActive) return
|
|
|
|
matrixMembersRequestActive = true
|
|
if (!silent) matrixMembersLoading.value = true
|
|
|
|
try {
|
|
const res = await $api("/api/communication/matrix/rooms/general/members")
|
|
matrixMembers.value = res.members || []
|
|
} catch (error) {
|
|
if (!silent) {
|
|
toast.add({
|
|
title: "Teilnehmer konnten nicht geladen werden",
|
|
color: "error"
|
|
})
|
|
}
|
|
} finally {
|
|
matrixMembersRequestActive = false
|
|
if (!silent) matrixMembersLoading.value = false
|
|
}
|
|
}
|
|
|
|
const loadGeneralChat = async ({ silent = false, includeMembers = false } = {}) => {
|
|
await loadGeneralMessages({ silent })
|
|
|
|
if (includeMembers) {
|
|
await loadGeneralMembers({ silent })
|
|
}
|
|
}
|
|
|
|
const sendMatrixMessage = async () => {
|
|
const text = matrixMessageDraft.value.trim()
|
|
if (!text || !canUseMatrixChat.value || !generalRoom.value?.exists) return
|
|
|
|
const optimisticId = `pending-${Date.now()}`
|
|
const optimisticMessage = {
|
|
id: optimisticId,
|
|
sender: identity.value?.matrixUserId || "Du",
|
|
senderDisplayName: identity.value?.displayName || "Du",
|
|
body: text,
|
|
timestamp: Date.now(),
|
|
own: true,
|
|
pending: true,
|
|
failed: false
|
|
}
|
|
|
|
matrixMessages.value = [
|
|
...matrixMessages.value,
|
|
optimisticMessage
|
|
]
|
|
matrixMessageDraft.value = ""
|
|
await scrollMessagesToBottom()
|
|
|
|
matrixMessageSending.value = true
|
|
try {
|
|
const message = await $api("/api/communication/matrix/rooms/general/messages", {
|
|
method: "POST",
|
|
body: { text }
|
|
})
|
|
|
|
matrixMessages.value = matrixMessages.value.map((item) =>
|
|
item.id === optimisticId ? message : item
|
|
)
|
|
} catch (error) {
|
|
matrixMessages.value = matrixMessages.value.map((item) =>
|
|
item.id === optimisticId ? { ...item, pending: false, failed: true } : item
|
|
)
|
|
toast.add({
|
|
title: "Nachricht konnte nicht gesendet werden",
|
|
color: "error"
|
|
})
|
|
} finally {
|
|
matrixMessageSending.value = false
|
|
}
|
|
}
|
|
|
|
const startMatrixAutoRefresh = () => {
|
|
if (matrixRefreshInterval) return
|
|
|
|
matrixAutoRefreshActive.value = true
|
|
matrixRefreshInterval = window.setInterval(() => {
|
|
if (!document.hidden && canUseMatrixChat.value && generalRoom.value?.exists) {
|
|
loadGeneralChat({ silent: true })
|
|
}
|
|
}, 15000)
|
|
}
|
|
|
|
const stopMatrixAutoRefresh = () => {
|
|
if (!matrixRefreshInterval) return
|
|
|
|
window.clearInterval(matrixRefreshInterval)
|
|
matrixRefreshInterval = null
|
|
matrixAutoRefreshActive.value = false
|
|
}
|
|
|
|
const formatMessageTime = (timestamp) => {
|
|
if (!timestamp) return ""
|
|
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "short",
|
|
timeStyle: "short"
|
|
}).format(new Date(timestamp))
|
|
}
|
|
|
|
const formatLastUpdated = computed(() => {
|
|
if (!lastUpdated.value) return "Noch nicht aktualisiert"
|
|
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
}).format(lastUpdated.value)
|
|
})
|
|
|
|
watch(
|
|
() => matrixMessages.value.length,
|
|
() => scrollMessagesToBottom()
|
|
)
|
|
|
|
onMounted(async () => {
|
|
await loadChatInfo()
|
|
startMatrixAutoRefresh()
|
|
})
|
|
|
|
onBeforeUnmount(stopMatrixAutoRefresh)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex min-h-0 flex-1 overflow-hidden">
|
|
<aside class="hidden w-72 shrink-0 flex-col border-r border-default bg-default lg:flex">
|
|
<div class="border-b border-default p-4">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h1 class="text-lg font-semibold text-highlighted">
|
|
Chat
|
|
</h1>
|
|
<p class="text-xs text-muted">
|
|
{{ formatLastUpdated }}
|
|
</p>
|
|
</div>
|
|
<UButton
|
|
icon="i-heroicons-arrow-path"
|
|
color="neutral"
|
|
variant="ghost"
|
|
:loading="loading || matrixMessagesLoading"
|
|
:disabled="!canUseMatrixChat"
|
|
@click="loadGeneralChat({ includeMembers: true })"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto p-3">
|
|
<button
|
|
v-for="room in rooms"
|
|
:key="room.key"
|
|
type="button"
|
|
class="mb-1 flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition"
|
|
:class="room.disabled ? 'cursor-not-allowed opacity-50' : 'bg-muted text-highlighted'"
|
|
:disabled="room.disabled"
|
|
>
|
|
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
|
<UIcon name="i-heroicons-chat-bubble-left-right" class="size-5" />
|
|
</span>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
|
|
<span class="block truncate text-xs text-muted">{{ room.description }}</span>
|
|
</span>
|
|
<UBadge
|
|
v-if="room.exists"
|
|
color="success"
|
|
variant="soft"
|
|
size="xs"
|
|
>
|
|
aktiv
|
|
</UBadge>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="border-t border-default p-3">
|
|
<UButton
|
|
to="/communication"
|
|
icon="i-heroicons-cog-6-tooth"
|
|
color="neutral"
|
|
variant="outline"
|
|
block
|
|
>
|
|
Matrix-Setup
|
|
</UButton>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="flex min-w-0 flex-1 flex-col bg-muted">
|
|
<header class="flex shrink-0 items-center justify-between gap-3 border-b border-default bg-default px-4 py-3">
|
|
<div class="min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<h2 class="truncate text-base font-semibold text-highlighted">
|
|
{{ activeRoom.name }}
|
|
</h2>
|
|
<UBadge
|
|
v-if="matrixAutoRefreshActive && generalRoom?.exists"
|
|
color="success"
|
|
variant="soft"
|
|
size="xs"
|
|
>
|
|
Live
|
|
</UBadge>
|
|
</div>
|
|
<p class="truncate text-xs text-muted">
|
|
<span v-if="matrixMembers.length">{{ matrixMembers.length }} Teilnehmer</span>
|
|
<span v-else>{{ activeRoom.description }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<UButton
|
|
class="lg:hidden"
|
|
to="/communication"
|
|
icon="i-heroicons-cog-6-tooth"
|
|
color="neutral"
|
|
variant="outline"
|
|
/>
|
|
<UButton
|
|
icon="i-heroicons-arrow-path"
|
|
color="neutral"
|
|
variant="outline"
|
|
:loading="matrixMessagesLoading"
|
|
:disabled="!canUseMatrixChat || !generalRoom?.exists"
|
|
@click="loadGeneralChat({ includeMembers: true })"
|
|
>
|
|
Laden
|
|
</UButton>
|
|
</div>
|
|
</header>
|
|
|
|
<div
|
|
v-if="status && (!status.reachable || !status.provisioningConfigured)"
|
|
class="border-b border-default bg-default px-4 py-3"
|
|
>
|
|
<UAlert
|
|
icon="i-heroicons-exclamation-triangle"
|
|
color="warning"
|
|
variant="soft"
|
|
title="Matrix ist noch nicht bereit"
|
|
description="Bitte prüfe das Matrix-Setup, bevor der Chat genutzt wird."
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="status && !generalRoom?.exists"
|
|
class="border-b border-default bg-default px-4 py-3"
|
|
>
|
|
<UAlert
|
|
icon="i-heroicons-chat-bubble-left-right"
|
|
color="primary"
|
|
variant="soft"
|
|
title="Allgemeiner Chat ist noch nicht eingerichtet"
|
|
description="Lege den mandantenweiten Startraum an, um FEDEO als Chatplattform zu nutzen."
|
|
>
|
|
<template #actions>
|
|
<UButton
|
|
icon="i-heroicons-plus"
|
|
:loading="roomProvisioning"
|
|
@click="provisionGeneralRoom"
|
|
>
|
|
Chat erstellen
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
</div>
|
|
|
|
<div
|
|
ref="matrixMessagesViewport"
|
|
class="min-h-0 flex-1 space-y-3 overflow-y-auto p-4 sm:p-5"
|
|
>
|
|
<div
|
|
v-if="!matrixMessages.length && !matrixMessagesLoading"
|
|
class="flex h-full min-h-64 items-center justify-center text-sm text-muted"
|
|
>
|
|
Noch keine Nachrichten im allgemeinen Chat.
|
|
</div>
|
|
|
|
<div
|
|
v-for="message in matrixMessages"
|
|
:key="message.id"
|
|
class="flex"
|
|
:class="message.own ? 'justify-end' : 'justify-start'"
|
|
>
|
|
<div
|
|
class="max-w-[82%] rounded-lg px-3 py-2 shadow-sm sm:max-w-[68%]"
|
|
:class="[
|
|
message.own ? 'bg-primary text-inverted' : 'bg-default text-highlighted',
|
|
message.pending ? 'opacity-70' : '',
|
|
message.failed ? 'bg-error text-inverted' : ''
|
|
]"
|
|
>
|
|
<div class="mb-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] opacity-75">
|
|
<span class="truncate font-medium">
|
|
{{ message.own ? "Du" : message.senderDisplayName || message.sender }}
|
|
</span>
|
|
<span>{{ formatMessageTime(message.timestamp) }}</span>
|
|
<span v-if="message.pending">wird gesendet</span>
|
|
<span v-if="message.failed">nicht gesendet</span>
|
|
</div>
|
|
<p class="whitespace-pre-wrap break-words text-sm">
|
|
{{ message.body }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
class="flex shrink-0 gap-2 border-t border-default bg-default p-3"
|
|
@submit.prevent="sendMatrixMessage"
|
|
>
|
|
<UInput
|
|
v-model="matrixMessageDraft"
|
|
class="min-w-0 flex-1"
|
|
placeholder="Nachricht schreiben"
|
|
:disabled="matrixMessageSending || !canUseMatrixChat || !generalRoom?.exists"
|
|
@keydown.enter.exact.prevent="sendMatrixMessage"
|
|
/>
|
|
<UButton
|
|
type="submit"
|
|
icon="i-heroicons-paper-airplane"
|
|
:loading="matrixMessageSending"
|
|
:disabled="!matrixMessageDraft.trim() || !canUseMatrixChat || !generalRoom?.exists"
|
|
>
|
|
Senden
|
|
</UButton>
|
|
</form>
|
|
</main>
|
|
|
|
<aside class="hidden w-80 shrink-0 border-l border-default bg-default xl:block">
|
|
<div class="border-b border-default p-4">
|
|
<h3 class="text-sm font-semibold text-highlighted">
|
|
Raumdetails
|
|
</h3>
|
|
<p class="mt-1 break-all font-mono text-xs text-muted">
|
|
{{ generalRoom?.roomId || generalRoom?.alias || "Noch kein Matrix-Raum" }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-5 p-4">
|
|
<div>
|
|
<div class="mb-2 flex items-center justify-between gap-2">
|
|
<h4 class="text-xs font-medium uppercase text-muted">
|
|
Teilnehmer
|
|
</h4>
|
|
<UButton
|
|
icon="i-heroicons-arrow-path"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
:loading="matrixMembersLoading"
|
|
:disabled="!canUseMatrixChat || !generalRoom?.exists"
|
|
@click="loadGeneralMembers"
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="member in matrixMembers"
|
|
:key="member.userId"
|
|
class="flex items-center gap-2"
|
|
>
|
|
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-medium text-highlighted">
|
|
{{ (member.displayName || member.userId || "?").slice(0, 1).toUpperCase() }}
|
|
</span>
|
|
<div class="min-w-0">
|
|
<p class="truncate text-sm font-medium text-highlighted">
|
|
{{ member.displayName || member.userId }}
|
|
</p>
|
|
<p class="truncate text-xs text-muted">
|
|
{{ member.userId }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p
|
|
v-if="!matrixMembers.length"
|
|
class="text-sm text-muted"
|
|
>
|
|
Noch keine Teilnehmer geladen.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 class="mb-2 text-xs font-medium uppercase text-muted">
|
|
Eigene Identität
|
|
</h4>
|
|
<p class="break-all font-mono text-xs text-highlighted">
|
|
{{ identity?.matrixUserId || "-" }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</template>
|