1791 lines
56 KiB
Vue
1791 lines
56 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 matrixMessageDraft = ref("")
|
|
const matrixMessagesViewport = ref(null)
|
|
const matrixAttachmentInput = ref(null)
|
|
const matrixAttachmentObjectUrls = 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 matrixMessageSending = ref(false)
|
|
const matrixAttachmentUploading = ref(false)
|
|
const matrixAutoRefreshActive = ref(false)
|
|
const lastUpdated = ref(null)
|
|
let matrixRefreshInterval = null
|
|
let matrixCallDurationInterval = null
|
|
let matrixMessagesRequestActive = false
|
|
let matrixMembersRequestActive = false
|
|
let matrixLiveKitRoom = null
|
|
const matrixCallVideoElements = new Map()
|
|
const matrixAttachmentPreviewRequests = new Set()
|
|
|
|
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 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 = []
|
|
await loadRoomChat({ includeMembers: true })
|
|
}
|
|
|
|
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 = []
|
|
|
|
toast.add({
|
|
title: "Chatraum ist bereit",
|
|
color: "success"
|
|
})
|
|
|
|
await loadRoomChat({ includeMembers: true })
|
|
} 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 || []) {
|
|
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))
|
|
loadAttachmentPreviews()
|
|
}
|
|
|
|
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 {
|
|
await $api(`${activeRoomEndpoint.value}/read`, {
|
|
method: "POST"
|
|
})
|
|
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] = 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")
|
|
])
|
|
|
|
status.value = statusRes
|
|
identity.value = identityRes
|
|
matrixRooms.value = roomsRes.rooms || []
|
|
matrixProjectRooms.value = projectRoomsRes.rooms || []
|
|
matrixDirectRooms.value = directRoomsRes.rooms || []
|
|
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 = []
|
|
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 || [])
|
|
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 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
|
|
|
|
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
|
|
)
|
|
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 uploadMatrixAttachment = async (event) => {
|
|
const file = event.target.files?.[0]
|
|
event.target.value = ""
|
|
|
|
if (!file || !canUseMatrixChat.value || !activeRoom.value?.exists) 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)
|
|
|
|
matrixAttachmentUploading.value = true
|
|
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"
|
|
})
|
|
} finally {
|
|
matrixAttachmentUploading.value = false
|
|
}
|
|
}
|
|
|
|
const startMatrixAutoRefresh = () => {
|
|
if (matrixRefreshInterval) return
|
|
|
|
matrixAutoRefreshActive.value = true
|
|
matrixRefreshInterval = window.setInterval(() => {
|
|
if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) {
|
|
loadRoomChat({ silent: true })
|
|
loadUnreadCounts()
|
|
}
|
|
}, 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 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="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
|
|
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"
|
|
>
|
|
<a
|
|
v-if="message.attachment.url"
|
|
:href="matrixMediaProxyUrl(message.attachment)"
|
|
target="_blank"
|
|
rel="noopener"
|
|
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>
|
|
</a>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
class="flex shrink-0 gap-2 border-t border-default bg-default p-3"
|
|
@submit.prevent="sendMatrixMessage"
|
|
>
|
|
<input
|
|
ref="matrixAttachmentInput"
|
|
type="file"
|
|
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="Nachricht schreiben"
|
|
:disabled="matrixMessageSending || matrixAttachmentUploading || !canUseMatrixChat || !activeRoom?.exists"
|
|
@keydown.enter.exact.prevent="sendMatrixMessage"
|
|
/>
|
|
<UButton
|
|
type="submit"
|
|
icon="i-heroicons-paper-airplane"
|
|
:loading="matrixMessageSending"
|
|
:disabled="!matrixMessageDraft.trim() || matrixAttachmentUploading || !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
|
|
: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>
|