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

2396 lines
76 KiB
Vue

<script setup>
import { ConnectionState, Room, RoomEvent, Track } from "livekit-client"
const toast = useToast()
const { $api } = useNuxtApp()
const route = useRoute()
const status = ref(null)
const identity = ref(null)
const matrixRooms = ref([])
const matrixProjectRooms = ref([])
const matrixDirectRooms = ref([])
const unreadRooms = ref({})
const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
const matrixMessages = ref([])
const matrixMembers = ref([])
const matrixTenantUsers = ref([])
const matrixMessageDraft = ref("")
const matrixMessagesViewport = ref(null)
const matrixAttachmentInput = ref(null)
const matrixAttachmentObjectUrls = ref({})
const matrixReplyTarget = ref(null)
const matrixEditingMessage = ref(null)
const matrixDragActive = ref(false)
const memberInviteOpen = ref(false)
const memberInviteUserId = ref("")
const matrixSearchQuery = ref("")
const matrixSearchResults = ref([])
const roomCreateOpen = ref(false)
const collapsedRoomGroups = ref({})
const matrixCallOpen = ref(false)
const matrixCallMode = ref("video")
const matrixCallLoading = ref(false)
const matrixCallConnected = ref(false)
const matrixCallState = ref("disconnected")
const matrixCallError = ref("")
const matrixCallSession = ref(null)
const matrixCallTiles = ref([])
const matrixCallMicEnabled = ref(true)
const matrixCallCameraEnabled = ref(true)
const matrixCallAudioContainer = ref(null)
const matrixCallScreenShareEnabled = ref(false)
const matrixCallStartedAt = ref(null)
const matrixCallDurationNow = ref(Date.now())
const matrixCallAnnouncementSent = ref(false)
const roomCreateForm = ref({
name: "",
key: "",
topic: ""
})
const loading = ref(false)
const roomProvisioning = ref(false)
const roomProvisioningKey = ref("")
const roomCreating = ref(false)
const roomMembersSyncing = ref(false)
const matrixMessagesLoading = ref(false)
const matrixMembersLoading = ref(false)
const memberInviting = ref(false)
const memberRemovingId = ref("")
const matrixSearchLoading = ref(false)
const matrixMessageSending = ref(false)
const matrixAttachmentUploading = ref(false)
const matrixAttachmentUploadCount = ref(0)
const matrixAutoRefreshActive = ref(false)
const lastUpdated = ref(null)
let matrixCallDurationInterval = null
let matrixMessagesRequestActive = false
let matrixMembersRequestActive = false
let matrixLiveKitRoom = null
let matrixLiveSyncRunId = 0
const matrixCallVideoElements = new Map()
const matrixAttachmentPreviewRequests = new Set()
const matrixSyncSince = ref("")
const canUseMatrixChat = computed(() =>
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
)
const activeRoom = computed(() =>
rooms.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 activeRoomMatrixAddress = computed(() =>
activeRoom.value?.roomId || activeRoom.value?.alias || ""
)
const canStartMatrixCall = computed(() =>
Boolean(canUseMatrixChat.value && activeRoom.value?.exists)
)
const canSendChatInput = computed(() =>
Boolean(canUseMatrixChat.value && activeRoom.value?.exists && !matrixMessageSending.value && !matrixAttachmentUploading.value)
)
const availableRoomInviteUsers = computed(() => {
const roomMemberIds = new Set(matrixMembers.value.map((member) => member.matrixUserId))
return matrixTenantUsers.value
.filter((user) => !roomMemberIds.has(user.matrixUserId))
.sort((first, second) =>
String(first.displayName || first.email || "").localeCompare(
String(second.displayName || second.email || ""),
"de",
{ sensitivity: "base" }
)
)
})
const matrixCallTitle = computed(() =>
matrixCallMode.value === "audio" ? "Audioanruf" : "Videokonferenz"
)
const matrixCallStateLabel = computed(() => {
const labels = {
connected: "Verbunden",
connecting: "Verbindet",
reconnecting: "Verbindung wird wiederhergestellt",
disconnected: "Getrennt"
}
return labels[String(matrixCallState.value).toLowerCase()] || matrixCallState.value
})
const matrixCallParticipantCount = computed(() =>
new Set(matrixCallTiles.value.map((tile) => tile.identity)).size
)
const matrixCallDurationLabel = computed(() => {
if (!matrixCallStartedAt.value) return "00:00"
const durationSeconds = Math.max(0, Math.floor((matrixCallDurationNow.value - matrixCallStartedAt.value) / 1000))
const minutes = String(Math.floor(durationSeconds / 60)).padStart(2, "0")
const seconds = String(durationSeconds % 60).padStart(2, "0")
return `${minutes}:${seconds}`
})
const roomCreateKeyPreview = computed(() =>
normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name)
)
const sortRoomsByName = (roomList) => [...roomList].sort((first, second) =>
String(first.name || "").localeCompare(String(second.name || ""), "de", { sensitivity: "base" })
)
const rooms = computed(() => {
const baseRooms = matrixRooms.value
.filter((room) => !["project", "direct"].includes(room.type))
.map((room) => ({
...room,
group: "Räume",
icon: "i-heroicons-chat-bubble-left-right",
unread: unreadRooms.value[room.key]?.count || 0,
mentions: unreadRooms.value[room.key]?.mentions || 0,
description: room.alias || room.roomId || "Mandantenweiter Austausch"
}))
const projectRooms = matrixProjectRooms.value.map((room) => ({
...room,
group: "Projekte",
icon: "i-heroicons-briefcase",
unread: unreadRooms.value[room.key]?.count || 0,
mentions: unreadRooms.value[room.key]?.mentions || 0,
description: room.projectNumber || room.topic || "Projektkommunikation",
provisionEndpoint: `/api/communication/matrix/project-rooms/${encodeURIComponent(room.projectId)}/provision`
}))
const directRooms = matrixDirectRooms.value.map((room) => ({
...room,
group: "Direkt",
icon: "i-heroicons-user-circle",
unread: unreadRooms.value[room.key]?.count || 0,
mentions: unreadRooms.value[room.key]?.mentions || 0,
description: room.email || room.topic || "Direktnachricht",
provisionEndpoint: `/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`
}))
return [
...sortRoomsByName(baseRooms),
...sortRoomsByName(projectRooms),
...sortRoomsByName(directRooms)
]
})
const groupedRooms = computed(() => [
{
key: "rooms",
label: "Räume",
rooms: rooms.value.filter((room) => room.group === "Räume")
},
{
key: "projects",
label: "Projekte",
rooms: rooms.value.filter((room) => room.group === "Projekte")
},
{
key: "direct",
label: "Direktnachrichten",
rooms: rooms.value.filter((room) => room.group === "Direkt")
}
].filter((group) => group.rooms.length > 0))
const isRoomGroupCollapsed = (groupKey) => Boolean(collapsedRoomGroups.value[groupKey])
const toggleRoomGroup = (groupKey) => {
collapsedRoomGroups.value = {
...collapsedRoomGroups.value,
[groupKey]: !isRoomGroupCollapsed(groupKey)
}
}
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
if (!room.exists && room.provisionEndpoint) {
await provisionRoomFromList(room)
return
}
activeRoomKey.value = room.key
matrixMessages.value = []
matrixMembers.value = []
matrixSearchResults.value = []
memberInviteOpen.value = false
await loadRoomChat({ includeMembers: true })
restartMatrixLiveSync()
}
const mergeRoomIntoLists = (room) => {
upsertRoom({ ...room, exists: true })
if (room.type === "project") {
matrixProjectRooms.value = matrixProjectRooms.value.map((item) =>
item.key === room.key ? { ...item, ...room, exists: true } : item
)
}
if (room.type === "direct") {
matrixDirectRooms.value = matrixDirectRooms.value.map((item) =>
item.key === room.key ? { ...item, ...room, exists: true } : item
)
}
}
const provisionRoomFromList = async (room) => {
roomProvisioningKey.value = room.key
try {
const createdRoom = await $api(room.provisionEndpoint, {
method: "POST"
})
mergeRoomIntoLists(createdRoom)
activeRoomKey.value = createdRoom.key
matrixMessages.value = []
matrixMembers.value = []
matrixSearchResults.value = []
memberInviteOpen.value = false
toast.add({
title: "Chatraum ist bereit",
color: "success"
})
await loadRoomChat({ includeMembers: true })
restartMatrixLiveSync()
} catch (error) {
toast.add({
title: "Chatraum konnte nicht erstellt werden",
color: "error"
})
} finally {
roomProvisioningKey.value = ""
}
}
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 || []) {
const member = matrixMembers.value.find((item) => item.matrixUserId === message.sender)
byId.set(message.id, {
...byId.get(message.id),
...message,
senderDisplayName: member?.displayName || message.senderDisplayName,
pending: false,
failed: false
})
}
matrixMessages.value = Array.from(byId.values())
.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0))
loadAttachmentPreviews()
}
const applyMatrixReplacements = (replacements) => {
if (!replacements?.length) return
const replacementByTarget = new Map()
for (const replacement of replacements) {
if (!replacement.targetEventId) continue
const previous = replacementByTarget.get(replacement.targetEventId)
if (!previous || (replacement.timestamp || 0) >= (previous.timestamp || 0)) {
replacementByTarget.set(replacement.targetEventId, replacement)
}
}
matrixMessages.value = matrixMessages.value.map((message) => {
const replacement = replacementByTarget.get(message.id)
if (!replacement) return message
return {
...message,
body: replacement.body,
edited: true,
timestamp: replacement.timestamp || message.timestamp
}
})
}
const applyMatrixReactions = (reactions) => {
if (!reactions?.length) return
matrixMessages.value = matrixMessages.value.map((message) => {
const messageReactions = reactions.filter((reaction) => reaction.targetEventId === message.id)
if (!messageReactions.length) return message
let nextReactions = [...(message.reactions || [])]
for (const reaction of messageReactions) {
const current = nextReactions.find((item) => item.key === reaction.key)
nextReactions = current
? nextReactions.map((item) => item.key === reaction.key ? {
...item,
count: item.count + 1,
own: item.own || reaction.own
} : item)
: [...nextReactions, { key: reaction.key, count: 1, own: reaction.own }]
}
return {
...message,
reactions: nextReactions
}
})
}
const applyMatrixRedactions = (redactions) => {
const redactedIds = new Set((redactions || []).map((redaction) => redaction.targetEventId).filter(Boolean))
if (!redactedIds.size) return
matrixMessages.value = matrixMessages.value.filter((message) => !redactedIds.has(message.id))
}
const findMessage = (messageId) =>
matrixMessages.value.find((message) => message.id === messageId)
const replyPreview = (message) => {
if (!message) return ""
return message.body || message.attachment?.fileName || "Nachricht"
}
const setReplyTarget = (message) => {
matrixEditingMessage.value = null
matrixReplyTarget.value = {
id: message.id,
senderDisplayName: message.own ? "Du" : message.senderDisplayName || message.sender,
body: replyPreview(message)
}
}
const clearReplyTarget = () => {
matrixReplyTarget.value = null
}
const beginEditMessage = (message) => {
if (!message?.id || !message.own || message.attachment) return
matrixEditingMessage.value = {
id: message.id,
body: message.body || ""
}
matrixMessageDraft.value = message.body || ""
clearReplyTarget()
}
const cancelEditMessage = () => {
matrixEditingMessage.value = null
matrixMessageDraft.value = ""
}
const scrollMessagesToBottom = async () => {
await nextTick()
if (!matrixMessagesViewport.value) return
matrixMessagesViewport.value.scrollTop = matrixMessagesViewport.value.scrollHeight
}
const loadUnreadCounts = async () => {
if (!canUseMatrixChat.value) return
try {
const res = await $api("/api/communication/matrix/unread")
unreadRooms.value = res.rooms || {}
} catch (error) {
unreadRooms.value = {}
}
}
const markActiveRoomRead = async () => {
if (!canUseMatrixChat.value || !activeRoom.value?.exists) return
try {
const latestMessage = matrixMessages.value.at(-1)
await $api(`${activeRoomEndpoint.value}/read`, {
method: "POST",
body: {
eventId: latestMessage?.id
}
})
unreadRooms.value = {
...unreadRooms.value,
[activeRoomKey.value]: { count: 0, mentions: 0 }
}
} catch (error) {
// Lesestatus ist Komfortfunktion; Chat selbst soll dadurch nicht blockieren.
}
}
const loadChatInfo = async () => {
loading.value = true
try {
const [statusRes, identityRes, roomsRes, projectRoomsRes, directRoomsRes, usersRes] = await Promise.all([
$api("/api/communication/matrix/status"),
$api("/api/communication/matrix/me"),
$api("/api/communication/matrix/rooms"),
$api("/api/communication/matrix/project-rooms"),
$api("/api/communication/matrix/direct-rooms"),
$api("/api/communication/matrix/users")
])
status.value = statusRes
identity.value = identityRes
matrixRooms.value = roomsRes.rooms || []
matrixProjectRooms.value = projectRoomsRes.rooms || []
matrixDirectRooms.value = directRoomsRes.rooms || []
matrixTenantUsers.value = usersRes.users || []
lastUpdated.value = new Date()
await loadUnreadCounts()
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 = []
matrixSearchResults.value = []
memberInviteOpen.value = false
roomCreateForm.value = {
name: "",
key: "",
topic: ""
}
roomCreateOpen.value = false
toast.add({
title: room.alreadyExisted ? "Chatraum ist bereit" : "Chatraum erstellt",
color: "success"
})
await loadRoomChat({ includeMembers: true })
restartMatrixLiveSync()
} 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 || [])
await markActiveRoomRead()
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 toggleMemberInvite = () => {
memberInviteOpen.value = !memberInviteOpen.value
if (memberInviteOpen.value && !memberInviteUserId.value && availableRoomInviteUsers.value[0]) {
memberInviteUserId.value = availableRoomInviteUsers.value[0].userId
}
}
const inviteRoomMember = async () => {
if (!memberInviteUserId.value || !canUseMatrixChat.value || !activeRoom.value?.exists) return
memberInviting.value = true
try {
const result = await $api(`${activeRoomEndpoint.value}/members/invite`, {
method: "POST",
body: {
userId: memberInviteUserId.value
}
})
toast.add({
title: "Teilnehmer eingeladen",
description: result.displayName || result.email || result.matrixUserId,
color: "success"
})
memberInviteUserId.value = ""
memberInviteOpen.value = false
await loadRoomMembers()
} catch (error) {
toast.add({
title: "Teilnehmer konnte nicht eingeladen werden",
color: "error"
})
} finally {
memberInviting.value = false
}
}
const removeRoomMember = async (member) => {
if (!member?.matrixUserId || member.own || !canUseMatrixChat.value || !activeRoom.value?.exists) return
memberRemovingId.value = member.matrixUserId
try {
await $api(`${activeRoomEndpoint.value}/members/${encodeURIComponent(member.matrixUserId)}`, {
method: "DELETE"
})
matrixMembers.value = matrixMembers.value.filter((item) => item.matrixUserId !== member.matrixUserId)
toast.add({
title: "Teilnehmer entfernt",
color: "success"
})
} catch (error) {
toast.add({
title: "Teilnehmer konnte nicht entfernt werden",
color: "error"
})
} finally {
memberRemovingId.value = ""
}
}
const searchRoomMessages = async () => {
const query = matrixSearchQuery.value.trim()
if (query.length < 2 || !canUseMatrixChat.value || !activeRoom.value?.exists) {
matrixSearchResults.value = []
return
}
matrixSearchLoading.value = true
try {
const res = await $api(`${activeRoomEndpoint.value}/search?q=${encodeURIComponent(query)}`)
matrixSearchResults.value = res.results || []
} catch (error) {
toast.add({
title: "Suche konnte nicht ausgeführt werden",
color: "error"
})
} finally {
matrixSearchLoading.value = false
}
}
const openMatrixCall = async (mode = "video") => {
if (matrixLiveKitRoom && matrixCallConnected.value) {
matrixCallOpen.value = true
return
}
if (!canStartMatrixCall.value) {
toast.add({
title: "Besprechung kann noch nicht gestartet werden",
color: "warning"
})
return
}
matrixCallMode.value = mode
matrixCallLoading.value = true
matrixCallError.value = ""
matrixCallOpen.value = true
matrixCallAnnouncementSent.value = false
try {
await leaveMatrixCall()
const session = await $api(`${activeRoomEndpoint.value}/call-session`, {
method: "POST",
body: { mode }
})
matrixCallSession.value = session
await connectMatrixCall(session, { video: mode === "video" })
await announceMatrixCallStarted(mode)
} catch (error) {
matrixCallError.value = error?.data?.error || error?.message || "Verbindung fehlgeschlagen"
toast.add({
title: "Besprechung konnte nicht gestartet werden",
description: matrixCallError.value,
color: "error"
})
} finally {
matrixCallLoading.value = false
}
}
const getParticipantTiles = (participant, local = false) => {
const publications = participant.getTrackPublications?.() || []
const screenSharePublication = publications.find((publication) =>
publication.kind === Track.Kind.Video &&
publication.source === Track.Source.ScreenShare &&
publication.track
)
const cameraPublication = publications.find((publication) =>
publication.kind === Track.Kind.Video &&
publication.source !== Track.Source.ScreenShare &&
publication.track
)
const audioPublication = publications.find((publication) =>
publication.kind === Track.Kind.Audio && publication.track
)
const participantId = local ? "local" : participant.sid || participant.identity
const name = local ? "Du" : participant.name || participant.identity
const participantTile = {
id: `${participantId}-camera`,
identity: participant.identity,
name,
local,
speaking: participant.isSpeaking,
screenSharing: false,
videoTrack: cameraPublication?.track || null,
audioTrack: audioPublication?.track || null,
cameraEnabled: participant.isCameraEnabled,
microphoneEnabled: participant.isMicrophoneEnabled,
}
if (!screenSharePublication?.track) {
return [participantTile]
}
return [
{
id: `${participantId}-screen`,
identity: participant.identity,
name,
local,
speaking: false,
screenSharing: true,
videoTrack: screenSharePublication.track,
audioTrack: null,
cameraEnabled: participant.isCameraEnabled,
microphoneEnabled: participant.isMicrophoneEnabled,
},
participantTile
]
}
const attachMatrixCallMedia = async () => {
await nextTick()
for (const tile of matrixCallTiles.value) {
const videoElement = matrixCallVideoElements.get(tile.id)
if (videoElement && tile.videoTrack) {
tile.videoTrack.attach(videoElement)
}
if (!tile.local && tile.audioTrack && matrixCallAudioContainer.value) {
const audioElement = tile.audioTrack.attach()
audioElement.autoplay = true
audioElement.dataset.participant = tile.id
matrixCallAudioContainer.value.appendChild(audioElement)
}
}
}
const refreshMatrixCallTiles = async () => {
if (!matrixLiveKitRoom) {
matrixCallTiles.value = []
return
}
matrixCallAudioContainer.value?.replaceChildren()
matrixCallTiles.value = [
...getParticipantTiles(matrixLiveKitRoom.localParticipant, true),
...Array.from(matrixLiveKitRoom.remoteParticipants.values()).flatMap((participant) =>
getParticipantTiles(participant)
)
].sort((a, b) => Number(b.screenSharing) - Number(a.screenSharing))
await attachMatrixCallMedia()
}
const startMatrixCallDurationTimer = () => {
matrixCallStartedAt.value = Date.now()
matrixCallDurationNow.value = Date.now()
if (matrixCallDurationInterval) {
window.clearInterval(matrixCallDurationInterval)
}
matrixCallDurationInterval = window.setInterval(() => {
matrixCallDurationNow.value = Date.now()
}, 1000)
}
const stopMatrixCallDurationTimer = () => {
if (matrixCallDurationInterval) {
window.clearInterval(matrixCallDurationInterval)
matrixCallDurationInterval = null
}
matrixCallStartedAt.value = null
matrixCallDurationNow.value = Date.now()
}
const announceMatrixCallStarted = async (mode) => {
if (matrixCallAnnouncementSent.value || !activeRoom.value?.exists) return
matrixCallAnnouncementSent.value = true
try {
const message = await $api(`${activeRoomEndpoint.value}/messages`, {
method: "POST",
body: {
text: `${mode === "audio" ? "Audioanruf" : "Videokonferenz"} gestartet.`
}
})
mergeMatrixMessages([message])
await scrollMessagesToBottom()
} catch (error) {
matrixCallAnnouncementSent.value = false
}
}
const connectMatrixCall = async (session, { video = true } = {}) => {
const room = new Room({
adaptiveStream: true,
dynacast: true,
})
matrixLiveKitRoom = room
matrixCallState.value = ConnectionState.Connecting
const refreshEvents = [
RoomEvent.TrackSubscribed,
RoomEvent.TrackUnsubscribed,
RoomEvent.LocalTrackPublished,
RoomEvent.LocalTrackUnpublished,
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
RoomEvent.TrackMuted,
RoomEvent.TrackUnmuted,
RoomEvent.ActiveSpeakersChanged,
RoomEvent.LocalTrackSubscribed,
]
for (const eventName of refreshEvents) {
room.on(eventName, refreshMatrixCallTiles)
}
room.on(RoomEvent.ConnectionStateChanged, (state) => {
matrixCallState.value = state
matrixCallConnected.value = state === ConnectionState.Connected
if (state === ConnectionState.Connected && !matrixCallStartedAt.value) {
startMatrixCallDurationTimer()
}
})
room.on(RoomEvent.Disconnected, () => {
matrixCallConnected.value = false
matrixCallState.value = ConnectionState.Disconnected
matrixCallScreenShareEnabled.value = false
stopMatrixCallDurationTimer()
})
await room.connect(session.liveKitUrl, session.liveKitToken)
await room.localParticipant.setMicrophoneEnabled(true)
await room.localParticipant.setCameraEnabled(video)
matrixCallMicEnabled.value = true
matrixCallCameraEnabled.value = video
matrixCallScreenShareEnabled.value = false
if (!matrixCallStartedAt.value) {
startMatrixCallDurationTimer()
}
await refreshMatrixCallTiles()
}
const leaveMatrixCall = async () => {
if (matrixLiveKitRoom) {
matrixLiveKitRoom.disconnect()
matrixLiveKitRoom = null
}
matrixCallVideoElements.clear()
matrixCallAudioContainer.value?.replaceChildren()
matrixCallTiles.value = []
matrixCallConnected.value = false
matrixCallState.value = "disconnected"
matrixCallScreenShareEnabled.value = false
matrixCallAnnouncementSent.value = false
stopMatrixCallDurationTimer()
}
const closeMatrixCall = async () => {
await leaveMatrixCall()
matrixCallOpen.value = false
}
const minimizeMatrixCall = () => {
matrixCallOpen.value = false
}
const setMatrixCallVideoElement = (tileId, element) => {
if (element) {
matrixCallVideoElements.set(tileId, element)
const tile = matrixCallTiles.value.find((item) => item.id === tileId)
tile?.videoTrack?.attach(element)
} else {
matrixCallVideoElements.delete(tileId)
}
}
const toggleMatrixCallMic = async () => {
if (!matrixLiveKitRoom) return
const enabled = !matrixCallMicEnabled.value
await matrixLiveKitRoom.localParticipant.setMicrophoneEnabled(enabled)
matrixCallMicEnabled.value = enabled
await refreshMatrixCallTiles()
}
const toggleMatrixCallCamera = async () => {
if (!matrixLiveKitRoom) return
const enabled = !matrixCallCameraEnabled.value
await matrixLiveKitRoom.localParticipant.setCameraEnabled(enabled)
matrixCallCameraEnabled.value = enabled
await refreshMatrixCallTiles()
}
const toggleMatrixCallScreenShare = async () => {
if (!matrixLiveKitRoom) return
const enabled = !matrixCallScreenShareEnabled.value
try {
await matrixLiveKitRoom.localParticipant.setScreenShareEnabled(enabled)
matrixCallScreenShareEnabled.value = enabled
await refreshMatrixCallTiles()
} catch (error) {
toast.add({
title: "Bildschirmfreigabe konnte nicht geändert werden",
color: "error"
})
}
}
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
if (matrixEditingMessage.value?.id) {
const editingId = matrixEditingMessage.value.id
matrixMessageSending.value = true
try {
const replacement = await $api(`${activeRoomEndpoint.value}/messages/${encodeURIComponent(editingId)}`, {
method: "PUT",
body: { text }
})
applyMatrixReplacements([replacement])
matrixMessageDraft.value = ""
matrixEditingMessage.value = null
} catch (error) {
toast.add({
title: "Nachricht konnte nicht bearbeitet werden",
color: "error"
})
} finally {
matrixMessageSending.value = false
}
return
}
const optimisticId = `pending-${Date.now()}`
const optimisticMessage = {
id: optimisticId,
sender: identity.value?.matrixUserId || "Du",
senderDisplayName: identity.value?.displayName || "Du",
body: text,
replyToEventId: matrixReplyTarget.value?.id || null,
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,
replyToEventId: matrixReplyTarget.value?.id
}
})
matrixMessages.value = matrixMessages.value.map((item) =>
item.id === optimisticId ? message : item
)
clearReplyTarget()
loadAttachmentPreviews()
} 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 openAttachmentPicker = () => {
if (!canUseMatrixChat.value || !activeRoom.value?.exists || matrixAttachmentUploading.value) return
matrixAttachmentInput.value?.click()
}
const uploadSingleMatrixAttachment = async (file) => {
if (!file || !canUseMatrixChat.value || !activeRoom.value?.exists) return
if (file.size > 25 * 1024 * 1024) {
toast.add({
title: "Anhang ist zu groß",
description: "Maximal erlaubt sind 25 MB.",
color: "warning"
})
return
}
const optimisticId = `attachment-${Date.now()}`
const optimisticMessage = {
id: optimisticId,
sender: identity.value?.matrixUserId || "Du",
senderDisplayName: identity.value?.displayName || "Du",
body: file.name,
attachment: {
fileName: file.name,
mimeType: file.type || "application/octet-stream",
size: file.size,
isImage: file.type?.startsWith("image/"),
},
timestamp: Date.now(),
own: true,
pending: true,
failed: false
}
matrixMessages.value = [
...matrixMessages.value,
optimisticMessage
]
await scrollMessagesToBottom()
const formData = new FormData()
formData.append("file", file)
try {
const message = await $api(`${activeRoomEndpoint.value}/attachments`, {
method: "POST",
body: formData
})
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: "Anhang konnte nicht gesendet werden",
color: "error"
})
}
}
const uploadMatrixFiles = async (files) => {
const fileList = Array.from(files || [])
if (!fileList.length || !canUseMatrixChat.value || !activeRoom.value?.exists) return
matrixAttachmentUploading.value = true
matrixAttachmentUploadCount.value = fileList.length
try {
for (const file of fileList) {
await uploadSingleMatrixAttachment(file)
}
} finally {
matrixAttachmentUploading.value = false
matrixAttachmentUploadCount.value = 0
matrixDragActive.value = false
}
}
const uploadMatrixAttachment = async (event) => {
const files = event.target.files
event.target.value = ""
await uploadMatrixFiles(files)
}
const handleAttachmentDrop = async (event) => {
matrixDragActive.value = false
await uploadMatrixFiles(event.dataTransfer?.files)
}
const downloadMatrixAttachment = async (attachment) => {
if (!attachment?.url) return
try {
const blob = await $api(matrixMediaProxyUrl(attachment), {
responseType: "blob"
})
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = objectUrl
link.download = attachment.fileName || "Anhang"
link.click()
URL.revokeObjectURL(objectUrl)
} catch (error) {
toast.add({
title: "Anhang konnte nicht geladen werden",
color: "error"
})
}
}
const reactToMatrixMessage = async (message, key) => {
if (!message?.id || !canUseMatrixChat.value || !activeRoom.value?.exists) return
try {
await $api(`${activeRoomEndpoint.value}/messages/${encodeURIComponent(message.id)}/reactions`, {
method: "POST",
body: { key }
})
const existing = message.reactions || []
const current = existing.find((reaction) => reaction.key === key)
const reactions = current
? existing.map((reaction) => reaction.key === key ? { ...reaction, count: reaction.count + 1, own: true } : reaction)
: [...existing, { key, count: 1, own: true }]
matrixMessages.value = matrixMessages.value.map((item) =>
item.id === message.id ? { ...item, reactions } : item
)
} catch (error) {
toast.add({
title: "Reaktion konnte nicht gesendet werden",
color: "error"
})
}
}
const deleteMatrixMessage = async (message) => {
if (!message?.id || !message.own || !canUseMatrixChat.value || !activeRoom.value?.exists) return
try {
await $api(`${activeRoomEndpoint.value}/messages/${encodeURIComponent(message.id)}`, {
method: "DELETE"
})
matrixMessages.value = matrixMessages.value.filter((item) => item.id !== message.id)
if (matrixEditingMessage.value?.id === message.id) {
cancelEditMessage()
}
} catch (error) {
toast.add({
title: "Nachricht konnte nicht gelöscht werden",
color: "error"
})
}
}
const applyMatrixSync = async (syncResult) => {
if (syncResult.nextBatch) {
matrixSyncSince.value = syncResult.nextBatch
}
mergeMatrixMessages(syncResult.messages || [])
applyMatrixReplacements(syncResult.replacements)
applyMatrixReactions(syncResult.reactions)
applyMatrixRedactions(syncResult.redactions)
if ((syncResult.messages || []).length) {
await markActiveRoomRead()
await scrollMessagesToBottom()
}
if (syncResult.membersChanged) {
await loadRoomMembers({ silent: true })
}
}
const runMatrixLiveSync = async (runId) => {
while (matrixLiveSyncRunId === runId) {
if (!canUseMatrixChat.value || !activeRoom.value?.exists || document.hidden) {
await new Promise((resolve) => window.setTimeout(resolve, 3000))
continue
}
try {
const params = new URLSearchParams()
if (matrixSyncSince.value) {
params.set("since", matrixSyncSince.value)
} else {
params.set("initial", "1")
}
const syncResult = await $api(`${activeRoomEndpoint.value}/sync?${params.toString()}`)
if (matrixLiveSyncRunId !== runId) return
await applyMatrixSync(syncResult)
await loadUnreadCounts()
lastUpdated.value = new Date()
} catch (error) {
if (matrixLiveSyncRunId !== runId) return
await new Promise((resolve) => window.setTimeout(resolve, 5000))
}
}
}
const startMatrixAutoRefresh = () => {
if (matrixAutoRefreshActive.value) return
matrixAutoRefreshActive.value = true
matrixLiveSyncRunId += 1
runMatrixLiveSync(matrixLiveSyncRunId)
}
const stopMatrixAutoRefresh = () => {
matrixLiveSyncRunId += 1
matrixAutoRefreshActive.value = false
}
const restartMatrixLiveSync = () => {
matrixSyncSince.value = ""
stopMatrixAutoRefresh()
startMatrixAutoRefresh()
}
const formatMessageTime = (timestamp) => {
if (!timestamp) return ""
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short"
}).format(new Date(timestamp))
}
const formatAttachmentSize = (size) => {
const bytes = Number(size || 0)
if (!bytes) return ""
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const matrixMediaProxyUrl = (attachment) => {
if (!attachment?.url) return ""
const params = new URLSearchParams({
uri: attachment.url,
name: attachment.fileName || "Anhang"
})
return `/api/communication/matrix/media?${params.toString()}`
}
const attachmentObjectUrl = (attachment) =>
attachment?.url ? matrixAttachmentObjectUrls.value[attachment.url] || "" : ""
const ensureAttachmentObjectUrl = async (attachment) => {
if (!attachment?.url || matrixAttachmentObjectUrls.value[attachment.url] || matrixAttachmentPreviewRequests.has(attachment.url)) return
matrixAttachmentPreviewRequests.add(attachment.url)
try {
const blob = await $api(matrixMediaProxyUrl(attachment), {
responseType: "blob"
})
const objectUrl = URL.createObjectURL(blob)
matrixAttachmentObjectUrls.value = {
...matrixAttachmentObjectUrls.value,
[attachment.url]: objectUrl
}
} catch (error) {
// Fällt nur auf den Link zurück; der Chat selbst soll weiter funktionieren.
} finally {
matrixAttachmentPreviewRequests.delete(attachment.url)
}
}
const loadAttachmentPreviews = () => {
for (const message of matrixMessages.value) {
if (message.attachment?.isImage && message.attachment.url) {
ensureAttachmentObjectUrl(message.attachment)
}
}
}
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()
stopMatrixCallDurationTimer()
leaveMatrixCall()
Object.values(matrixAttachmentObjectUrls.value).forEach((objectUrl) => URL.revokeObjectURL(objectUrl))
})
</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 space-y-4 overflow-y-auto p-3">
<section
v-for="group in groupedRooms"
:key="group.key"
class="space-y-1"
>
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left text-muted transition hover:bg-muted hover:text-highlighted"
:aria-expanded="!isRoomGroupCollapsed(group.key)"
@click="toggleRoomGroup(group.key)"
>
<span class="flex min-w-0 items-center gap-2">
<UIcon
name="i-heroicons-chevron-right"
class="size-3.5 shrink-0 transition-transform"
:class="!isRoomGroupCollapsed(group.key) ? 'rotate-90' : ''"
/>
<span class="truncate text-[11px] font-semibold uppercase tracking-wide">
{{ group.label }}
</span>
</span>
<span class="text-[11px]">{{ group.rooms.length }}</span>
</button>
<div
v-if="!isRoomGroupCollapsed(group.key)"
class="space-y-1"
>
<button
v-for="room in group.rooms"
:key="room.key"
type="button"
class="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="room.icon || '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="roomProvisioningKey === room.key"
color="primary"
variant="soft"
size="xs"
>
lädt
</UBadge>
<UBadge
v-else-if="room.unread"
:color="room.mentions ? 'error' : 'primary'"
variant="solid"
size="xs"
>
{{ room.mentions ? `@${room.mentions}` : room.unread }}
</UBadge>
<UBadge
v-else-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>
</section>
</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"
:loading="matrixCallLoading && matrixCallMode === 'audio'"
:disabled="!canStartMatrixCall"
@click="openMatrixCall('audio')"
/>
<UButton
icon="i-heroicons-video-camera"
color="primary"
variant="soft"
aria-label="Videokonferenz starten"
:loading="matrixCallLoading && matrixCallMode === 'video'"
:disabled="!canStartMatrixCall"
@click="openMatrixCall('video')"
/>
<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)"
>
<span>{{ room.name }}</span>
<UBadge
v-if="room.unread"
class="ml-2"
:color="room.mentions ? 'error' : 'primary'"
variant="solid"
size="xs"
>
{{ room.mentions ? `@${room.mentions}` : room.unread }}
</UBadge>
</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
v-if="matrixLiveKitRoom && matrixCallConnected"
class="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-primary/20 bg-primary/10 px-4 py-3"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-highlighted">
{{ matrixCallTitle }} läuft in {{ activeRoom.name }}
</p>
<p class="truncate text-xs text-muted">
{{ matrixCallParticipantCount }} Teilnehmer · {{ matrixCallDurationLabel }} · {{ matrixCallStateLabel }}
</p>
</div>
<div class="flex items-center gap-2">
<UButton
icon="i-heroicons-arrow-top-right-on-square"
color="primary"
variant="soft"
size="sm"
@click="matrixCallOpen = true"
>
Öffnen
</UButton>
<UButton
icon="i-heroicons-phone-x-mark"
color="error"
variant="soft"
size="sm"
@click="closeMatrixCall"
>
Auflegen
</UButton>
</div>
</div>
<div
ref="matrixMessagesViewport"
class="relative min-h-0 flex-1 space-y-3 overflow-y-auto p-4 sm:p-5"
:class="matrixDragActive ? 'ring-2 ring-inset ring-primary' : ''"
@dragenter.prevent="matrixDragActive = true"
@dragover.prevent="matrixDragActive = true"
@dragleave.prevent="matrixDragActive = false"
@drop.prevent="handleAttachmentDrop"
>
<div
v-if="matrixDragActive"
class="pointer-events-none absolute inset-4 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-primary bg-primary/10 text-sm font-medium text-primary"
>
Dateien hier ablegen
</div>
<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>
<span v-if="message.edited">bearbeitet</span>
</div>
<button
v-if="message.replyToEventId"
type="button"
class="mb-2 block w-full rounded border px-2 py-1 text-left text-xs opacity-80"
:class="message.own ? 'border-white/20' : 'border-default'"
>
Antwort auf {{ findMessage(message.replyToEventId)?.senderDisplayName || "Nachricht" }}:
{{ replyPreview(findMessage(message.replyToEventId)) }}
</button>
<p class="whitespace-pre-wrap break-words text-sm">
{{ message.body }}
</p>
<div
v-if="message.attachment"
class="mt-2 overflow-hidden rounded-md border"
:class="message.own ? 'border-white/20' : 'border-default'"
>
<img
v-if="message.attachment.isImage && attachmentObjectUrl(message.attachment)"
:src="attachmentObjectUrl(message.attachment)"
:alt="message.attachment.fileName || message.body"
class="max-h-72 w-full object-contain"
>
<button
v-if="message.attachment.url"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm"
@click="downloadMatrixAttachment(message.attachment)"
>
<UIcon name="i-heroicons-paper-clip" class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate">
{{ message.attachment.fileName || message.body }}
</span>
<span class="shrink-0 text-xs opacity-70">
{{ formatAttachmentSize(message.attachment.size) }}
</span>
</button>
<div
v-else
class="flex items-center gap-2 px-3 py-2 text-sm"
>
<UIcon name="i-heroicons-paper-clip" class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate">
{{ message.attachment.fileName || message.body }}
</span>
<span class="shrink-0 text-xs opacity-70">
{{ formatAttachmentSize(message.attachment.size) }}
</span>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-1">
<button
v-for="reaction in message.reactions || []"
:key="reaction.key"
type="button"
class="rounded-full px-2 py-0.5 text-xs"
:class="reaction.own ? 'bg-white/20' : 'bg-muted'"
@click="reactToMatrixMessage(message, reaction.key)"
>
{{ reaction.key }} {{ reaction.count }}
</button>
<button
v-for="reactionKey in ['👍', '✅', '👀']"
:key="reactionKey"
type="button"
class="rounded-full px-2 py-0.5 text-xs opacity-70 hover:opacity-100"
:class="message.own ? 'bg-white/10' : 'bg-muted'"
@click="reactToMatrixMessage(message, reactionKey)"
>
{{ reactionKey }}
</button>
<button
type="button"
class="rounded-full px-2 py-0.5 text-xs opacity-70 hover:opacity-100"
:class="message.own ? 'bg-white/10' : 'bg-muted'"
@click="setReplyTarget(message)"
>
Antworten
</button>
<button
v-if="message.own && !message.attachment && !message.pending"
type="button"
class="rounded-full px-2 py-0.5 text-xs opacity-70 hover:opacity-100"
:class="message.own ? 'bg-white/10' : 'bg-muted'"
@click="beginEditMessage(message)"
>
Bearbeiten
</button>
<button
v-if="message.own && !message.pending"
type="button"
class="rounded-full px-2 py-0.5 text-xs opacity-70 hover:opacity-100"
:class="message.own ? 'bg-white/10' : 'bg-muted'"
@click="deleteMatrixMessage(message)"
>
Löschen
</button>
</div>
</div>
</div>
</div>
<div
v-if="matrixEditingMessage"
class="flex shrink-0 items-center gap-3 border-t border-default bg-default px-3 py-2 text-sm"
>
<UIcon name="i-heroicons-pencil-square" class="size-4 text-muted" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs text-muted">Nachricht bearbeiten</p>
<p class="truncate text-highlighted">{{ matrixEditingMessage.body }}</p>
</div>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="xs"
@click="cancelEditMessage"
/>
</div>
<div
v-if="matrixReplyTarget"
class="flex shrink-0 items-center gap-3 border-t border-default bg-default px-3 py-2 text-sm"
>
<UIcon name="i-heroicons-arrow-uturn-left" class="size-4 text-muted" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs text-muted">Antwort an {{ matrixReplyTarget.senderDisplayName }}</p>
<p class="truncate text-highlighted">{{ matrixReplyTarget.body }}</p>
</div>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="xs"
@click="clearReplyTarget"
/>
</div>
<form
class="flex shrink-0 gap-2 border-t border-default bg-default p-3"
@submit.prevent="sendMatrixMessage"
>
<input
ref="matrixAttachmentInput"
type="file"
multiple
class="hidden"
@change="uploadMatrixAttachment"
>
<UButton
type="button"
icon="i-heroicons-paper-clip"
color="neutral"
variant="outline"
:loading="matrixAttachmentUploading"
:disabled="matrixAttachmentUploading || !canUseMatrixChat || !activeRoom?.exists"
@click="openAttachmentPicker"
/>
<UInput
v-model="matrixMessageDraft"
class="min-w-0 flex-1"
:placeholder="matrixEditingMessage ? 'Änderung schreiben' : (matrixAttachmentUploading ? `${matrixAttachmentUploadCount} Anhang/Anhänge werden hochgeladen` : 'Nachricht schreiben')"
:disabled="!canSendChatInput"
@keydown.enter.exact.prevent="sendMatrixMessage"
/>
<UButton
type="submit"
icon="i-heroicons-paper-airplane"
:loading="matrixMessageSending"
:disabled="!matrixMessageDraft.trim() || !canSendChatInput"
>
{{ matrixEditingMessage ? "Speichern" : "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>
<h4 class="mb-2 text-xs font-medium uppercase text-muted">
Suche
</h4>
<form
class="flex gap-2"
@submit.prevent="searchRoomMessages"
>
<UInput
v-model="matrixSearchQuery"
size="sm"
class="min-w-0 flex-1"
placeholder="Nachrichten suchen"
:disabled="!canUseMatrixChat || !activeRoom?.exists"
/>
<UButton
type="submit"
icon="i-heroicons-magnifying-glass"
size="sm"
color="neutral"
variant="outline"
:loading="matrixSearchLoading"
:disabled="matrixSearchQuery.trim().length < 2 || !canUseMatrixChat || !activeRoom?.exists"
/>
</form>
<div
v-if="matrixSearchResults.length"
class="mt-3 max-h-52 space-y-2 overflow-y-auto"
>
<button
v-for="result in matrixSearchResults"
:key="result.id"
type="button"
class="block w-full rounded-md border border-default px-3 py-2 text-left hover:bg-muted"
@click="setReplyTarget(result)"
>
<div class="mb-1 flex items-center justify-between gap-2 text-xs text-muted">
<span class="truncate">{{ result.senderDisplayName || result.sender }}</span>
<span class="shrink-0">{{ formatMessageTime(result.timestamp) }}</span>
</div>
<p class="line-clamp-2 text-sm text-highlighted">
{{ result.body || result.attachment?.fileName || "Anhang" }}
</p>
</button>
</div>
<p
v-else-if="matrixSearchQuery.trim().length >= 2 && !matrixSearchLoading"
class="mt-3 text-sm text-muted"
>
Keine Treffer in diesem Raum.
</p>
</div>
<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-users"
color="neutral"
variant="ghost"
size="xs"
:loading="roomMembersSyncing"
:disabled="!canUseMatrixChat || !activeRoom?.exists"
@click="syncRoomMembers"
/>
<UButton
icon="i-heroicons-plus"
color="neutral"
variant="ghost"
size="xs"
:disabled="!canUseMatrixChat || !activeRoom?.exists || !availableRoomInviteUsers.length"
@click="toggleMemberInvite"
/>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="ghost"
size="xs"
:loading="matrixMembersLoading"
:disabled="!canUseMatrixChat || !activeRoom?.exists"
@click="loadRoomMembers"
/>
</div>
</div>
<form
v-if="memberInviteOpen"
class="mb-3 rounded-md border border-default p-2"
@submit.prevent="inviteRoomMember"
>
<select
v-model="memberInviteUserId"
class="mb-2 w-full rounded-md border border-default bg-default px-2 py-1.5 text-sm text-highlighted"
:disabled="memberInviting"
>
<option
v-for="user in availableRoomInviteUsers"
:key="user.userId"
:value="user.userId"
>
{{ user.displayName || user.email }}
</option>
</select>
<UButton
type="submit"
icon="i-heroicons-user-plus"
size="xs"
block
:loading="memberInviting"
:disabled="!memberInviteUserId || memberInviting"
>
Einladen
</UButton>
</form>
<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>
<UButton
v-if="!member.own"
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="xs"
class="ml-auto"
:loading="memberRemovingId === member.matrixUserId"
:disabled="Boolean(memberRemovingId)"
@click="removeRoomMember(member)"
/>
</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
:loading="matrixCallLoading && matrixCallMode === 'audio'"
:disabled="!canStartMatrixCall"
@click="openMatrixCall('audio')"
>
Audio
</UButton>
<UButton
icon="i-heroicons-video-camera"
color="primary"
variant="soft"
block
:loading="matrixCallLoading && matrixCallMode === 'video'"
: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
icon="i-heroicons-microphone"
color="neutral"
:variant="matrixCallMicEnabled ? 'soft' : 'outline'"
:disabled="!matrixLiveKitRoom"
@click="toggleMatrixCallMic"
>
{{ matrixCallMicEnabled ? "Mikro an" : "Mikro aus" }}
</UButton>
<UButton
icon="i-heroicons-video-camera"
color="neutral"
:variant="matrixCallCameraEnabled ? 'soft' : 'outline'"
:disabled="!matrixLiveKitRoom"
@click="toggleMatrixCallCamera"
>
{{ matrixCallCameraEnabled ? "Kamera an" : "Kamera aus" }}
</UButton>
<UButton
icon="i-heroicons-computer-desktop"
color="neutral"
:variant="matrixCallScreenShareEnabled ? 'soft' : 'outline'"
:disabled="!matrixLiveKitRoom"
@click="toggleMatrixCallScreenShare"
>
{{ matrixCallScreenShareEnabled ? "Freigabe an" : "Freigeben" }}
</UButton>
<UButton
icon="i-heroicons-arrows-pointing-in"
color="neutral"
variant="ghost"
:disabled="!matrixLiveKitRoom"
@click="minimizeMatrixCall"
>
Minimieren
</UButton>
<UButton
icon="i-heroicons-phone-x-mark"
color="error"
variant="soft"
@click="closeMatrixCall"
>
Auflegen
</UButton>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
@click="closeMatrixCall"
/>
</div>
</header>
<div class="min-h-0 flex-1 bg-muted">
<div
v-if="matrixCallLoading"
class="flex h-full items-center justify-center text-sm text-muted"
>
Besprechung wird gestartet...
</div>
<div
v-else-if="matrixCallError"
class="flex h-full items-center justify-center p-6"
>
<UAlert
icon="i-heroicons-exclamation-triangle"
color="error"
variant="soft"
title="Besprechung konnte nicht gestartet werden"
:description="matrixCallError"
/>
</div>
<div
v-else-if="canStartMatrixCall"
class="flex h-full flex-col"
>
<div class="flex shrink-0 items-center justify-between border-b border-default bg-default px-4 py-2 text-xs text-muted">
<span>{{ matrixCallParticipantCount }} Teilnehmer · {{ matrixCallDurationLabel }}</span>
<span>{{ matrixCallStateLabel }}</span>
</div>
<div class="grid min-h-0 flex-1 auto-rows-fr gap-3 overflow-y-auto p-3 sm:grid-cols-2 xl:grid-cols-3">
<div
v-for="tile in matrixCallTiles"
:key="tile.id"
class="relative min-h-52 overflow-hidden rounded-lg bg-inverted text-inverted"
:class="tile.speaking ? 'ring-2 ring-primary' : ''"
>
<video
v-if="tile.videoTrack"
:ref="(element) => setMatrixCallVideoElement(tile.id, element)"
class="h-full w-full"
:class="tile.screenSharing ? 'object-contain bg-black' : 'object-cover'"
autoplay
playsinline
:muted="tile.local"
/>
<div
v-else
class="flex h-full min-h-52 items-center justify-center bg-elevated text-highlighted"
>
<span class="flex size-16 items-center justify-center rounded-lg bg-primary/10 text-xl font-semibold text-primary">
{{ (tile.name || tile.identity || "?").slice(0, 1).toUpperCase() }}
</span>
</div>
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/55 px-3 py-2 text-sm text-white">
<span class="truncate">
{{ tile.name }}
<span v-if="tile.screenSharing" class="ml-1 text-xs opacity-80">teilt Bildschirm</span>
</span>
<div class="flex items-center gap-2">
<UIcon
v-if="tile.screenSharing"
name="i-heroicons-computer-desktop"
class="size-4"
/>
<UIcon
v-if="!tile.screenSharing"
:name="tile.microphoneEnabled ? 'i-heroicons-microphone' : 'i-heroicons-microphone'"
class="size-4"
:class="tile.microphoneEnabled ? 'opacity-100' : 'opacity-35'"
/>
<UIcon
v-if="!tile.screenSharing"
:name="tile.cameraEnabled ? 'i-heroicons-video-camera' : 'i-heroicons-video-camera-slash'"
class="size-4"
/>
</div>
</div>
</div>
</div>
<div ref="matrixCallAudioContainer" class="hidden" />
</div>
<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 LiveKit ist noch nicht bereit."
/>
</div>
</div>
</div>
</template>
</UModal>
</div>
</template>