diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts index 84309d5..048f2f9 100644 --- a/backend/src/modules/matrix.service.ts +++ b/backend/src/modules/matrix.service.ts @@ -25,6 +25,18 @@ type MatrixRoomEvent = { mimetype?: string size?: number } + "m.new_content"?: { + body?: string + msgtype?: string + } + "m.relates_to"?: { + event_id?: string + key?: string + rel_type?: string + "m.in_reply_to"?: { + event_id?: string + } + } } } @@ -71,6 +83,10 @@ type MatrixAttachmentInput = { size: number } +type MatrixMessageOptions = { + replyToEventId?: string +} + type MatrixCachedValue = { exists: true cachedUntil: number @@ -1158,27 +1174,72 @@ export function matrixService(server: FastifyInstance) { session.accessToken ) + const replacementByEventId = new Map() + const reactionsByEventId = new Map>() + + for (const event of response.chunk) { + const relation = event.content?.["m.relates_to"] + + if ( + event.type === "m.room.message" && + relation?.rel_type === "m.replace" && + relation.event_id + ) { + replacementByEventId.set(relation.event_id, event) + } + + if ( + event.type === "m.reaction" && + relation?.rel_type === "m.annotation" && + relation.event_id && + relation.key + ) { + const eventReactions = reactionsByEventId.get(relation.event_id) || new Map() + const reaction = eventReactions.get(relation.key) || { + key: relation.key, + count: 0, + own: false, + } + + reaction.count += 1 + reaction.own = reaction.own || event.sender === session.matrixUserId + eventReactions.set(relation.key, reaction) + reactionsByEventId.set(relation.event_id, eventReactions) + } + } + + const messages = response.chunk + .filter((event) => + event.type === "m.room.message" && + ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") && + event.content?.["m.relates_to"]?.rel_type !== "m.replace" + ) + .map((event) => { + const replacement = replacementByEventId.get(event.event_id) + const content = replacement?.content?.["m.new_content"] || replacement?.content || event.content + + return { + id: event.event_id, + sender: event.sender, + senderDisplayName: members.joined[event.sender]?.display_name || event.sender, + body: content?.body || "", + attachment: attachmentFromEvent({ ...event, content }), + timestamp: replacement?.origin_server_ts || event.origin_server_ts, + own: event.sender === session.matrixUserId, + edited: Boolean(replacement), + replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, + reactions: Array.from(reactionsByEventId.get(event.event_id)?.values() || []), + } + }) + .reverse() + return { roomId: room.roomId, alias: room.alias, key: room.key, name: room.name, matrixUserId: session.matrixUserId, - messages: response.chunk - .filter((event) => - event.type === "m.room.message" && - ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") - ) - .map((event) => ({ - id: event.event_id, - sender: event.sender, - senderDisplayName: members.joined[event.sender]?.display_name || event.sender, - body: event.content?.body || "", - attachment: attachmentFromEvent(event), - timestamp: event.origin_server_ts, - own: event.sender === session.matrixUserId, - })) - .reverse(), + messages, } } @@ -1216,7 +1277,8 @@ export function matrixService(server: FastifyInstance) { userId: string, tenantId: number | null, options: MatrixTenantRoomOptions = {}, - text: string + text: string, + messageOptions: MatrixMessageOptions = {} ) => { const message = text.trim() @@ -1235,16 +1297,26 @@ export function matrixService(server: FastifyInstance) { }) const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` + const content: Record = { + msgtype: "m.text", + body: message, + } + + if (messageOptions.replyToEventId) { + content["m.relates_to"] = { + "m.in_reply_to": { + event_id: messageOptions.replyToEventId, + }, + } + } + const response = await requestMatrixJson<{ event_id: string }>( `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, session.accessToken, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - msgtype: "m.text", - body: message, - }), + body: JSON.stringify(content), } ) @@ -1258,9 +1330,78 @@ export function matrixService(server: FastifyInstance) { roomId: room.roomId, alias: room.alias, key: room.key, + replyToEventId: messageOptions.replyToEventId || null, + reactions: [], } } + const sendTenantRoomReaction = async ( + userId: string, + tenantId: number | null, + options: MatrixTenantRoomOptions = {}, + eventId: string, + key: string + ) => { + const reactionKey = key.trim() + if (!eventId || !reactionKey) { + throw Object.assign( + new Error("Reaction target and key are required"), + { statusCode: 400 } + ) + } + + const room = await provisionTenantRoom(userId, tenantId, options) + const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { + roomId: room.roomId, + alias: room.alias, + }) + const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` + await requestMatrixJson<{ event_id: string }>( + `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.reaction/${encodeURIComponent(txnId)}`, + session.accessToken, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + "m.relates_to": { + rel_type: "m.annotation", + event_id: eventId, + key: reactionKey, + }, + }), + } + ) + + return { success: true, eventId, key: reactionKey } + } + + const markTenantRoomRead = async ( + userId: string, + tenantId: number | null, + options: MatrixTenantRoomOptions = {}, + eventId: string + ) => { + if (!eventId) return { success: true, skipped: true } + + const room = await provisionTenantRoom(userId, tenantId, options) + const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { + roomId: room.roomId, + alias: room.alias, + }) + + await requestMatrixJson( + `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/receipt/m.read/${encodeURIComponent(eventId)}`, + session.accessToken, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ) + + return { success: true, eventId } + } + const sendTenantRoomAttachment = async ( userId: string, tenantId: number | null, @@ -1579,7 +1720,12 @@ export function matrixService(server: FastifyInstance) { name: "Allgemeiner Chat", }) - const sendGeneralRoomMessage = (userId: string, tenantId: number | null, text: string) => + const sendGeneralRoomMessage = ( + userId: string, + tenantId: number | null, + text: string, + messageOptions: MatrixMessageOptions = {} + ) => sendTenantRoomMessage( userId, tenantId, @@ -1587,7 +1733,8 @@ export function matrixService(server: FastifyInstance) { key: "allgemein", name: "Allgemeiner Chat", }, - text + text, + messageOptions ) const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) => @@ -1615,6 +1762,8 @@ export function matrixService(server: FastifyInstance) { getTenantRoomMessages, getTenantRoomMembers, sendTenantRoomMessage, + sendTenantRoomReaction, + markTenantRoomRead, sendTenantRoomAttachment, getMediaContent, createElementRoomSession, diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index ccbecea..562ad94 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -641,8 +641,10 @@ export default async function communicationRoutes(server: FastifyInstance) { server.post("/communication/matrix/rooms/general/messages", async (req, reply) => { try { - const body = req.body as { text?: string } - const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "") + const body = req.body as { text?: string; replyToEventId?: string } + const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "", { + replyToEventId: body.replyToEventId, + }) const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") await notifyUsersAboutChatMessage(req, room, message, body.text || "") return message @@ -688,7 +690,12 @@ export default async function communicationRoutes(server: FastifyInstance) { try { if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) const params = req.params as { roomKey: string } - return await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey) + const body = (req.body || {}) as { eventId?: string } + const result = await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey) + if (body.eventId) { + await matrix.markTenantRoomRead(req.user.user_id, req.user.tenant_id, roomOptionsFromRequest(req), body.eventId) + } + return result } catch (err: any) { return handleMatrixError(req, reply, err, "Matrix room read state failed") } @@ -759,12 +766,15 @@ export default async function communicationRoutes(server: FastifyInstance) { server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { try { - const body = req.body as { text?: string } + const body = req.body as { text?: string; replyToEventId?: string } const message = await matrix.sendTenantRoomMessage( req.user.user_id, req.user.tenant_id, roomOptionsFromRequest(req), - body.text || "" + body.text || "", + { + replyToEventId: body.replyToEventId, + } ) const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key) await notifyUsersAboutChatMessage(req, room, message, body.text || "") @@ -774,6 +784,22 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + server.post("/communication/matrix/rooms/:roomKey/messages/:eventId/reactions", async (req, reply) => { + try { + const params = req.params as { eventId: string } + const body = req.body as { key?: string } + return await matrix.sendTenantRoomReaction( + req.user.user_id, + req.user.tenant_id, + roomOptionsFromRequest(req), + params.eventId, + body.key || "" + ) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix reaction send failed") + } + }) + server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => { try { const attachment = await uploadedAttachmentFromRequest(req) diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index 29ebc17..4d3f67b 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -18,6 +18,8 @@ const matrixMessageDraft = ref("") const matrixMessagesViewport = ref(null) const matrixAttachmentInput = ref(null) const matrixAttachmentObjectUrls = ref({}) +const matrixReplyTarget = ref(null) +const matrixDragActive = ref(false) const roomCreateOpen = ref(false) const collapsedRoomGroups = ref({}) const matrixCallOpen = ref(false) @@ -49,6 +51,7 @@ const matrixMessagesLoading = ref(false) const matrixMembersLoading = ref(false) const matrixMessageSending = ref(false) const matrixAttachmentUploading = ref(false) +const matrixAttachmentUploadCount = ref(0) const matrixAutoRefreshActive = ref(false) const lastUpdated = ref(null) let matrixRefreshInterval = null @@ -86,6 +89,10 @@ const canStartMatrixCall = computed(() => Boolean(canUseMatrixChat.value && activeRoom.value?.exists) ) +const canSendChatInput = computed(() => + Boolean(canUseMatrixChat.value && activeRoom.value?.exists && !matrixMessageSending.value && !matrixAttachmentUploading.value) +) + const matrixCallTitle = computed(() => matrixCallMode.value === "audio" ? "Audioanruf" : "Videokonferenz" ) @@ -291,6 +298,26 @@ const mergeMatrixMessages = (incomingMessages) => { loadAttachmentPreviews() } +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) => { + matrixReplyTarget.value = { + id: message.id, + senderDisplayName: message.own ? "Du" : message.senderDisplayName || message.sender, + body: replyPreview(message) + } +} + +const clearReplyTarget = () => { + matrixReplyTarget.value = null +} + const scrollMessagesToBottom = async () => { await nextTick() if (!matrixMessagesViewport.value) return @@ -313,8 +340,12 @@ const markActiveRoomRead = async () => { if (!canUseMatrixChat.value || !activeRoom.value?.exists) return try { + const latestMessage = matrixMessages.value.at(-1) await $api(`${activeRoomEndpoint.value}/read`, { - method: "POST" + method: "POST", + body: { + eventId: latestMessage?.id + } }) unreadRooms.value = { ...unreadRooms.value, @@ -825,6 +856,7 @@ const sendMatrixMessage = async () => { sender: identity.value?.matrixUserId || "Du", senderDisplayName: identity.value?.displayName || "Du", body: text, + replyToEventId: matrixReplyTarget.value?.id || null, timestamp: Date.now(), own: true, pending: true, @@ -842,12 +874,16 @@ const sendMatrixMessage = async () => { try { const message = await $api(`${activeRoomEndpoint.value}/messages`, { method: "POST", - body: { text } + 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) => @@ -867,12 +903,18 @@ const openAttachmentPicker = () => { matrixAttachmentInput.value?.click() } -const uploadMatrixAttachment = async (event) => { - const file = event.target.files?.[0] - event.target.value = "" - +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, @@ -900,7 +942,6 @@ const uploadMatrixAttachment = async (event) => { const formData = new FormData() formData.append("file", file) - matrixAttachmentUploading.value = true try { const message = await $api(`${activeRoomEndpoint.value}/attachments`, { method: "POST", @@ -918,8 +959,81 @@ const uploadMatrixAttachment = async (event) => { 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" + }) } } @@ -1400,8 +1514,19 @@ onBeforeUnmount(() => {
+
+ Dateien hier ablegen +
{ {{ formatMessageTime(message.timestamp) }} wird gesendet nicht gesendet + bearbeitet
+

{{ message.body }}

@@ -1445,12 +1580,11 @@ onBeforeUnmount(() => { :alt="message.attachment.fileName || message.body" class="max-h-72 w-full object-contain" > - @@ -1459,7 +1593,7 @@ onBeforeUnmount(() => { {{ formatAttachmentSize(message.attachment.size) }} - +
{
+
+ + + +
+
+ +
+

Antwort an {{ matrixReplyTarget.senderDisplayName }}

+

{{ matrixReplyTarget.body }}

+
+ +
+
{ @@ -1499,15 +1682,15 @@ onBeforeUnmount(() => { Senden