Files
FEDEO/frontend/pages/communication/chat.vue

988 lines
29 KiB
Vue

<script setup>
const toast = useToast()
const { $api } = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const status = ref(null)
const identity = ref(null)
const matrixRooms = ref([])
const activeRoomKey = ref("allgemein")
const matrixMessages = ref([])
const matrixMembers = ref([])
const matrixMessageDraft = ref("")
const matrixMessagesViewport = ref(null)
const roomCreateOpen = ref(false)
const matrixCallOpen = ref(false)
const matrixCallMode = ref("video")
const roomCreateForm = ref({
name: "",
key: "",
topic: ""
})
const loading = ref(false)
const roomProvisioning = ref(false)
const roomCreating = ref(false)
const roomMembersSyncing = 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(() =>
matrixRooms.value.find((room) => room.key === activeRoomKey.value) || {
key: activeRoomKey.value,
name: activeRoomKey.value,
description: "Mandantenweiter Austausch",
exists: false,
roomId: "",
unread: 0
}
)
const activeRoomEndpoint = computed(() =>
`/api/communication/matrix/rooms/${encodeURIComponent(activeRoomKey.value)}`
)
const matrixElementUrl = computed(() =>
String(runtimeConfig.public?.matrixElementUrl || "").replace(/\/+$/, "")
)
const activeRoomMatrixAddress = computed(() =>
activeRoom.value?.roomId || activeRoom.value?.alias || ""
)
const activeRoomElementUrl = computed(() => {
if (!matrixElementUrl.value || !activeRoomMatrixAddress.value) return ""
return `${matrixElementUrl.value}/#/room/${encodeURIComponent(activeRoomMatrixAddress.value)}`
})
const canStartMatrixCall = computed(() =>
Boolean(canUseMatrixChat.value && activeRoom.value?.exists && activeRoomElementUrl.value)
)
const matrixCallTitle = computed(() =>
matrixCallMode.value === "audio" ? "Audioanruf" : "Videokonferenz"
)
const roomCreateKeyPreview = computed(() =>
normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name)
)
const rooms = computed(() => [
...matrixRooms.value.map((room) => ({
...room,
description: room.alias || room.roomId || "Mandantenweiter Austausch"
})),
{
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 normalizeRoomKey = (value) => {
const normalized = String(value || "")
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ä/g, "a")
.replace(/ö/g, "o")
.replace(/ü/g, "u")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9._=-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[._=-]+|[._=-]+$/g, "")
return normalized || "raum"
}
const setActiveRoom = async (room) => {
if (room.disabled || room.key === activeRoomKey.value) return
activeRoomKey.value = room.key
matrixMessages.value = []
matrixMembers.value = []
await loadRoomChat({ includeMembers: true })
}
const upsertRoom = (room) => {
const roomWasKnown = matrixRooms.value.some((item) => item.key === room.key)
matrixRooms.value = roomWasKnown
? matrixRooms.value.map((item) => item.key === room.key ? { ...item, ...room } : item)
: [...matrixRooms.value, room]
}
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, roomsRes] = await Promise.all([
$api("/api/communication/matrix/status"),
$api("/api/communication/matrix/me"),
$api("/api/communication/matrix/rooms")
])
status.value = statusRes
identity.value = identityRes
matrixRooms.value = roomsRes.rooms || []
lastUpdated.value = new Date()
if (activeRoom.value?.exists && canUseMatrixChat.value) {
await loadRoomChat({ silent: true, includeMembers: true })
}
} catch (error) {
toast.add({
title: "Chat konnte nicht geladen werden",
color: "error"
})
} finally {
loading.value = false
}
}
const provisionActiveRoom = async () => {
roomProvisioning.value = true
try {
const res = await $api(`${activeRoomEndpoint.value}/provision`, {
method: "POST"
})
upsertRoom({ ...res, exists: true })
toast.add({
title: res.alreadyExisted ? "Chatraum ist bereit" : "Chatraum erstellt",
color: "success"
})
await loadRoomChat({ includeMembers: true })
} catch (error) {
toast.add({
title: "Chatraum konnte nicht erstellt werden",
color: "error"
})
} finally {
roomProvisioning.value = false
}
}
const createRoom = async () => {
const name = roomCreateForm.value.name.trim()
const key = roomCreateKeyPreview.value
const topic = roomCreateForm.value.topic.trim()
if (!name) {
toast.add({
title: "Bitte gib einen Raumnamen ein",
color: "warning"
})
return
}
roomCreating.value = true
try {
const room = await $api("/api/communication/matrix/rooms", {
method: "POST",
body: {
key,
name,
topic: topic || undefined,
type: "room"
}
})
upsertRoom({ ...room, exists: true })
activeRoomKey.value = room.key
matrixMessages.value = []
matrixMembers.value = []
roomCreateForm.value = {
name: "",
key: "",
topic: ""
}
roomCreateOpen.value = false
toast.add({
title: room.alreadyExisted ? "Chatraum ist bereit" : "Chatraum erstellt",
color: "success"
})
await loadRoomChat({ includeMembers: true })
} catch (error) {
toast.add({
title: "Chatraum konnte nicht erstellt werden",
color: "error"
})
} finally {
roomCreating.value = false
}
}
const loadRoomMessages = async ({ silent = false } = {}) => {
if (!canUseMatrixChat.value || !activeRoom.value?.exists) return
if (matrixMessagesRequestActive) return
matrixMessagesRequestActive = true
if (!silent) matrixMessagesLoading.value = true
try {
const res = await $api(`${activeRoomEndpoint.value}/messages`)
mergeMatrixMessages(res.messages || [])
matrixRooms.value = matrixRooms.value.map((room) => room.key === activeRoomKey.value ? {
...room,
alias: res.alias || room.alias,
exists: true,
roomId: res.roomId || room.roomId
} : room)
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 loadRoomMembers = async ({ silent = false } = {}) => {
if (!canUseMatrixChat.value || !activeRoom.value?.exists) return
if (matrixMembersRequestActive) return
matrixMembersRequestActive = true
if (!silent) matrixMembersLoading.value = true
try {
const res = await $api(`${activeRoomEndpoint.value}/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 syncRoomMembers = async () => {
if (!canUseMatrixChat.value || !activeRoom.value?.exists) return
roomMembersSyncing.value = true
try {
const res = await $api(`${activeRoomEndpoint.value}/members/sync`, {
method: "POST"
})
toast.add({
title: "Teilnehmer synchronisiert",
description: `${res.joined || 0} synchronisiert, ${res.failed || 0} fehlgeschlagen`,
color: res.failed ? "warning" : "success"
})
await loadRoomMembers()
} catch (error) {
toast.add({
title: "Teilnehmer konnten nicht synchronisiert werden",
color: "error"
})
} finally {
roomMembersSyncing.value = false
}
}
const openMatrixCall = (mode = "video") => {
if (!canStartMatrixCall.value) {
toast.add({
title: "Besprechung kann noch nicht gestartet werden",
color: "warning"
})
return
}
matrixCallMode.value = mode
matrixCallOpen.value = true
}
const loadRoomChat = async ({ silent = false, includeMembers = false } = {}) => {
await loadRoomMessages({ silent })
if (includeMembers) {
await loadRoomMembers({ silent })
}
}
const sendMatrixMessage = async () => {
const text = matrixMessageDraft.value.trim()
if (!text || !canUseMatrixChat.value || !activeRoom.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(`${activeRoomEndpoint.value}/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 && activeRoom.value?.exists) {
loadRoomChat({ 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="loadRoomChat({ includeMembers: true })"
/>
</div>
</div>
<div class="border-b border-default p-3">
<UButton
icon="i-heroicons-plus"
color="primary"
variant="soft"
block
:disabled="!canUseMatrixChat"
@click="roomCreateOpen = !roomCreateOpen"
>
Raum erstellen
</UButton>
<form
v-if="roomCreateOpen"
class="mt-3 space-y-3 rounded-md border border-default bg-muted p-3"
@submit.prevent="createRoom"
>
<UInput
v-model="roomCreateForm.name"
placeholder="Raumname"
:disabled="roomCreating"
/>
<UInput
v-model="roomCreateForm.key"
placeholder="Raum-Key optional"
:disabled="roomCreating"
/>
<UTextarea
v-model="roomCreateForm.topic"
placeholder="Thema optional"
:rows="2"
:disabled="roomCreating"
/>
<p class="truncate text-xs text-muted">
Key: {{ roomCreateKeyPreview }}
</p>
<div class="flex justify-end gap-2">
<UButton
type="button"
color="neutral"
variant="ghost"
:disabled="roomCreating"
@click="roomCreateOpen = false"
>
Abbrechen
</UButton>
<UButton
type="submit"
icon="i-heroicons-check"
:loading="roomCreating"
:disabled="!roomCreateForm.name.trim()"
>
Erstellen
</UButton>
</div>
</form>
</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' : 'text-highlighted hover:bg-muted',
room.key === activeRoomKey ? 'bg-muted ring-1 ring-primary/20' : ''
]"
:disabled="room.disabled"
@click="setActiveRoom(room)"
>
<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>
<UBadge
v-else-if="!room.disabled"
color="neutral"
variant="soft"
size="xs"
>
offen
</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 && activeRoom?.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
icon="i-heroicons-phone"
color="neutral"
variant="outline"
aria-label="Audioanruf starten"
:disabled="!canStartMatrixCall"
@click="openMatrixCall('audio')"
/>
<UButton
icon="i-heroicons-video-camera"
color="primary"
variant="soft"
aria-label="Videokonferenz starten"
:disabled="!canStartMatrixCall"
@click="openMatrixCall('video')"
/>
<UButton
class="lg:hidden"
to="/communication"
icon="i-heroicons-cog-6-tooth"
color="neutral"
variant="outline"
/>
<UButton
class="lg:hidden"
icon="i-heroicons-plus"
color="primary"
variant="soft"
:disabled="!canUseMatrixChat"
@click="roomCreateOpen = !roomCreateOpen"
/>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="outline"
:loading="matrixMessagesLoading"
:disabled="!canUseMatrixChat || !activeRoom?.exists"
@click="loadRoomChat({ includeMembers: true })"
>
Laden
</UButton>
</div>
</header>
<div class="border-b border-default bg-default p-3 lg:hidden">
<div class="flex gap-2 overflow-x-auto pb-1">
<button
v-for="room in rooms"
:key="room.key"
type="button"
class="shrink-0 rounded-md border border-default px-3 py-2 text-left text-sm"
:class="room.key === activeRoomKey ? 'bg-muted text-highlighted' : 'text-muted'"
:disabled="room.disabled"
@click="setActiveRoom(room)"
>
{{ room.name }}
</button>
</div>
<form
v-if="roomCreateOpen"
class="mt-3 grid gap-2 rounded-md border border-default bg-muted p-3"
@submit.prevent="createRoom"
>
<UInput
v-model="roomCreateForm.name"
placeholder="Raumname"
:disabled="roomCreating"
/>
<UInput
v-model="roomCreateForm.key"
placeholder="Raum-Key optional"
:disabled="roomCreating"
/>
<UTextarea
v-model="roomCreateForm.topic"
placeholder="Thema optional"
:rows="2"
:disabled="roomCreating"
/>
<p class="truncate text-xs text-muted">
Key: {{ roomCreateKeyPreview }}
</p>
<div class="flex justify-end gap-2">
<UButton
type="button"
color="neutral"
variant="ghost"
:disabled="roomCreating"
@click="roomCreateOpen = false"
>
Abbrechen
</UButton>
<UButton
type="submit"
icon="i-heroicons-check"
:loading="roomCreating"
:disabled="!roomCreateForm.name.trim()"
>
Erstellen
</UButton>
</div>
</form>
</div>
<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 && !activeRoom?.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="`${activeRoom.name} ist noch nicht in Matrix angelegt`"
description="Die Raum-Metadaten sind in FEDEO vorhanden. Lege jetzt den Matrix-Raum dazu an."
>
<template #actions>
<UButton
icon="i-heroicons-plus"
:loading="roomProvisioning"
@click="provisionActiveRoom"
>
Matrix-Raum 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 in diesem 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 || !activeRoom?.exists"
@keydown.enter.exact.prevent="sendMatrixMessage"
/>
<UButton
type="submit"
icon="i-heroicons-paper-airplane"
:loading="matrixMessageSending"
:disabled="!matrixMessageDraft.trim() || !canUseMatrixChat || !activeRoom?.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">
{{ activeRoom?.roomId || activeRoom?.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>
<div class="flex items-center gap-1">
<UButton
icon="i-heroicons-user-plus"
color="neutral"
variant="ghost"
size="xs"
:loading="roomMembersSyncing"
:disabled="!canUseMatrixChat || !activeRoom?.exists"
@click="syncRoomMembers"
/>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="ghost"
size="xs"
:loading="matrixMembersLoading"
:disabled="!canUseMatrixChat || !activeRoom?.exists"
@click="loadRoomMembers"
/>
</div>
</div>
<div class="space-y-2">
<div
v-for="member in matrixMembers"
:key="member.matrixUserId"
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.matrixUserId || "?").slice(0, 1).toUpperCase() }}
</span>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-highlighted">
{{ member.displayName || member.matrixUserId }}
</p>
<p class="truncate text-xs text-muted">
{{ member.matrixUserId }}
</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">
Besprechung
</h4>
<div class="grid grid-cols-2 gap-2">
<UButton
icon="i-heroicons-phone"
color="neutral"
variant="outline"
block
:disabled="!canStartMatrixCall"
@click="openMatrixCall('audio')"
>
Audio
</UButton>
<UButton
icon="i-heroicons-video-camera"
color="primary"
variant="soft"
block
:disabled="!canStartMatrixCall"
@click="openMatrixCall('video')"
>
Video
</UButton>
</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>
<UModal
v-model:open="matrixCallOpen"
fullscreen
class="h-[100dvh]"
>
<template #content>
<div class="flex h-[100dvh] flex-col bg-default">
<header class="flex shrink-0 items-center justify-between gap-3 border-b border-default px-4 py-3">
<div class="min-w-0">
<h3 class="truncate text-base font-semibold text-highlighted">
{{ matrixCallTitle }} · {{ activeRoom.name }}
</h3>
<p class="truncate text-xs text-muted">
{{ activeRoomMatrixAddress }}
</p>
</div>
<div class="flex items-center gap-2">
<UButton
v-if="activeRoomElementUrl"
:to="activeRoomElementUrl"
target="_blank"
icon="i-heroicons-arrow-top-right-on-square"
color="neutral"
variant="outline"
>
Extern öffnen
</UButton>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
@click="matrixCallOpen = false"
/>
</div>
</header>
<div class="min-h-0 flex-1 bg-muted">
<iframe
v-if="canStartMatrixCall"
:key="`${activeRoomKey}-${matrixCallMode}`"
:src="activeRoomElementUrl"
class="h-full w-full border-0"
allow="camera; microphone; display-capture; clipboard-read; clipboard-write; fullscreen; autoplay"
referrerpolicy="no-referrer"
/>
<div
v-else
class="flex h-full items-center justify-center p-6"
>
<UAlert
icon="i-heroicons-exclamation-triangle"
color="warning"
variant="soft"
title="Besprechung nicht verfügbar"
description="Der Matrix-Raum oder die Element-Integration ist noch nicht bereit."
/>
</div>
</div>
</div>
</template>
</UModal>
</div>
</template>