diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts index 3ac1e5a..0d870ec 100644 --- a/backend/src/modules/matrix.service.ts +++ b/backend/src/modules/matrix.service.ts @@ -22,6 +22,13 @@ type MatrixRoomEvent = { } } +type MatrixJoinedMembersResponse = { + joined: Record +} + const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "") const readLocalDevRegistrationSharedSecret = () => { if (process.env.NODE_ENV === "production") return "" @@ -687,6 +694,10 @@ export function matrixService(server: FastifyInstance) { `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/messages?dir=b&limit=50`, session.accessToken ) + const members = await requestMatrixJson( + `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, + session.accessToken + ) return { roomId: room.roomId, @@ -697,6 +708,7 @@ export function matrixService(server: FastifyInstance) { .map((event) => ({ id: event.event_id, sender: event.sender, + senderDisplayName: members.joined[event.sender]?.display_name || event.sender, body: event.content?.body || "", timestamp: event.origin_server_ts, own: event.sender === session.matrixUserId, @@ -705,6 +717,33 @@ export function matrixService(server: FastifyInstance) { } } + const getGeneralRoomMembers = async (userId: string, tenantId: number | null) => { + const room = await provisionTenantRoom(userId, tenantId, { + key: "allgemein", + name: "Allgemeiner Chat", + }) + + const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { + roomId: room.roomId, + alias: room.alias, + }) + const members = await requestMatrixJson( + `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, + session.accessToken + ) + + return { + roomId: room.roomId, + alias: room.alias, + members: Object.entries(members.joined).map(([matrixUserId, member]) => ({ + matrixUserId, + displayName: member.display_name || matrixUserId, + avatarUrl: member.avatar_url || null, + own: matrixUserId === session.matrixUserId, + })), + } + } + const sendGeneralRoomMessage = async ( userId: string, tenantId: number | null, @@ -746,6 +785,7 @@ export function matrixService(server: FastifyInstance) { return { id: response.event_id, sender: session.matrixUserId, + senderDisplayName: await getCurrentUserDisplayName(userId, tenantId), body: message, timestamp: Date.now(), own: true, @@ -765,6 +805,7 @@ export function matrixService(server: FastifyInstance) { provisionTenantRoom, createAccessTokenForUser, getGeneralRoomMessages, + getGeneralRoomMembers, sendGeneralRoomMessage, } } diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 69c1142..df76c1f 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -86,6 +86,17 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + server.get("/communication/matrix/rooms/general/members", async (req, reply) => { + try { + return await matrix.getGeneralRoomMembers(req.user.user_id, req.user.tenant_id) + } catch (err: any) { + req.log.error(err) + return reply + .code(err.statusCode || 500) + .send({ error: err.message || "Matrix members failed" }) + } + }) + server.post("/communication/matrix/rooms/general/messages", async (req, reply) => { try { const body = req.body as { text?: string } diff --git a/frontend/pages/communication/index.vue b/frontend/pages/communication/index.vue index 580342d..2fcfc05 100644 --- a/frontend/pages/communication/index.vue +++ b/frontend/pages/communication/index.vue @@ -11,14 +11,19 @@ const provisionResult = ref(null) const tenantSpaceProvisionResult = ref(null) const generalRoomProvisionResult = ref(null) const matrixMessages = ref([]) +const matrixMembers = ref([]) const matrixMessageDraft = ref("") +const matrixMessagesViewport = ref(null) const loading = ref(false) const provisioning = ref(false) const tenantSpaceProvisioning = ref(false) const generalRoomProvisioning = ref(false) const matrixMessagesLoading = ref(false) +const matrixMembersLoading = ref(false) const matrixMessageSending = ref(false) +const matrixAutoRefreshActive = ref(false) const lastUpdated = ref(null) +let matrixRefreshInterval = null const statusItems = computed(() => [ { @@ -72,6 +77,37 @@ const embeddedElementUrl = computed(() => { return `${elementBaseUrl.value}/#/room/${encodeURIComponent(activeMatrixTargetId.value)}` }) +const canUseMatrixChat = computed(() => + Boolean(status.value?.reachable && status.value?.provisioningConfigured) +) + +const mergeMatrixMessages = (incomingMessages) => { + const byId = new Map() + + for (const message of matrixMessages.value) { + byId.set(message.id, message) + } + + for (const message of incomingMessages || []) { + byId.set(message.id, { + ...byId.get(message.id), + ...message, + pending: false, + failed: false + }) + } + + matrixMessages.value = Array.from(byId.values()) + .sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)) +} + +const scrollMessagesToBottom = async () => { + await nextTick() + if (!matrixMessagesViewport.value) return + + matrixMessagesViewport.value.scrollTop = matrixMessagesViewport.value.scrollHeight +} + const loadMatrixInfo = async () => { loading.value = true try { @@ -88,8 +124,8 @@ const loadMatrixInfo = async () => { generalRoom.value = generalRoomRes lastUpdated.value = new Date() - if (generalRoomRes?.exists) { - await loadGeneralMessages() + if (generalRoomRes?.exists && canUseMatrixChat.value) { + await loadGeneralChat({ silent: true }) } } catch (error) { toast.add({ @@ -189,7 +225,7 @@ const provisionGeneralRoom = async () => { color: "success" }) - await loadGeneralMessages() + await loadGeneralChat() } catch (error) { toast.add({ title: "Allgemeiner Chat konnte nicht erstellt werden", @@ -200,31 +236,81 @@ const provisionGeneralRoom = async () => { } } -const loadGeneralMessages = async () => { - matrixMessagesLoading.value = true +const loadGeneralMessages = async ({ silent = false } = {}) => { + if (!canUseMatrixChat.value) return + if (!silent) matrixMessagesLoading.value = true + try { const res = await $api("/api/communication/matrix/rooms/general/messages") - matrixMessages.value = res.messages || [] + mergeMatrixMessages(res.messages || []) generalRoom.value = { ...generalRoom.value, alias: res.alias || generalRoom.value?.alias, exists: true, roomId: res.roomId || generalRoom.value?.roomId } + await scrollMessagesToBottom() } catch (error) { - toast.add({ - title: "Matrix-Nachrichten konnten nicht geladen werden", - color: "error" - }) + if (!silent) { + toast.add({ + title: "Matrix-Nachrichten konnten nicht geladen werden", + color: "error" + }) + } } finally { - matrixMessagesLoading.value = false + if (!silent) matrixMessagesLoading.value = false } } +const loadGeneralMembers = async ({ silent = false } = {}) => { + if (!canUseMatrixChat.value) return + if (!silent) matrixMembersLoading.value = true + + try { + const res = await $api("/api/communication/matrix/rooms/general/members") + matrixMembers.value = res.members || [] + } catch (error) { + if (!silent) { + toast.add({ + title: "Matrix-Teilnehmer konnten nicht geladen werden", + color: "error" + }) + } + } finally { + if (!silent) matrixMembersLoading.value = false + } +} + +const loadGeneralChat = async ({ silent = false } = {}) => { + await Promise.all([ + loadGeneralMessages({ silent }), + loadGeneralMembers({ silent }) + ]) +} + const sendMatrixMessage = async () => { const text = matrixMessageDraft.value.trim() if (!text) return + const optimisticId = `pending-${Date.now()}` + const optimisticMessage = { + id: optimisticId, + sender: identity.value?.matrixUserId || "Du", + senderDisplayName: identity.value?.displayName || "Du", + body: text, + timestamp: Date.now(), + own: true, + pending: true, + failed: false + } + + matrixMessages.value = [ + ...matrixMessages.value, + optimisticMessage + ] + matrixMessageDraft.value = "" + await scrollMessagesToBottom() + matrixMessageSending.value = true try { const message = await $api("/api/communication/matrix/rooms/general/messages", { @@ -232,12 +318,14 @@ const sendMatrixMessage = async () => { body: { text } }) - matrixMessages.value = [ - ...matrixMessages.value, - message - ] - matrixMessageDraft.value = "" + matrixMessages.value = matrixMessages.value.map((item) => + item.id === optimisticId ? message : item + ) + await loadGeneralMembers({ silent: true }) } catch (error) { + matrixMessages.value = matrixMessages.value.map((item) => + item.id === optimisticId ? { ...item, pending: false, failed: true } : item + ) toast.add({ title: "Matrix-Nachricht konnte nicht gesendet werden", color: "error" @@ -247,6 +335,25 @@ const sendMatrixMessage = async () => { } } +const startMatrixAutoRefresh = () => { + if (matrixRefreshInterval) return + + matrixAutoRefreshActive.value = true + matrixRefreshInterval = window.setInterval(() => { + if (!document.hidden && canUseMatrixChat.value) { + loadGeneralChat({ silent: true }) + } + }, 5000) +} + +const stopMatrixAutoRefresh = () => { + if (!matrixRefreshInterval) return + + window.clearInterval(matrixRefreshInterval) + matrixRefreshInterval = null + matrixAutoRefreshActive.value = false +} + const formatDateTime = (value) => { if (!value) return "-" @@ -265,7 +372,17 @@ const formatMessageTime = (timestamp) => { }).format(new Date(timestamp)) } -onMounted(loadMatrixInfo) +watch( + () => matrixMessages.value.length, + () => scrollMessagesToBottom() +) + +onMounted(async () => { + await loadMatrixInfo() + startMatrixAutoRefresh() +}) + +onBeforeUnmount(stopMatrixAutoRefresh)
-
+
- {{ message.own ? "Du" : message.sender }} + {{ message.own ? "Du" : message.senderDisplayName || message.sender }} {{ formatMessageTime(message.timestamp) }} + wird gesendet + nicht gesendet

{{ message.body }} @@ -639,13 +776,14 @@ onMounted(loadMatrixInfo) v-model="matrixMessageDraft" class="min-w-0 flex-1" placeholder="Nachricht schreiben" - :disabled="matrixMessageSending || !status?.reachable || !status?.provisioningConfigured" + :disabled="matrixMessageSending || !canUseMatrixChat" + @keydown.enter.exact.prevent="sendMatrixMessage" /> Senden