feat(communication): integrate collaboration rooms into normal chat
This commit is contained in:
@@ -285,6 +285,8 @@ Als erster nutzbarer Meilenstein wird **ein abgegrenzter Projekt-Unterraum mit e
|
|||||||
|
|
||||||
Unter **Kommunikation → Zusammenarbeit** können interne Teilnehmer ihre eigenen Unterräume und Einladungen öffnen. Projektverantwortliche mit `organisation.projects.write` beziehungsweise Administratoren können Unterräume verwalten und freie Partnerchats anlegen. Der Projektkopf verlinkt ebenfalls auf die Zusammenarbeit. Die Verwaltung eines Raums berechtigt weiterhin nicht zum Lesen seiner Nachrichten; nur der Ersteller wird bei der Anlage automatisch Teilnehmer.
|
Unter **Kommunikation → Zusammenarbeit** können interne Teilnehmer ihre eigenen Unterräume und Einladungen öffnen. Projektverantwortliche mit `organisation.projects.write` beziehungsweise Administratoren können Unterräume verwalten und freie Partnerchats anlegen. Der Projektkopf verlinkt ebenfalls auf die Zusammenarbeit. Die Verwaltung eines Raums berechtigt weiterhin nicht zum Lesen seiner Nachrichten; nur der Ersteller wird bei der Anlage automatisch Teilnehmer.
|
||||||
|
|
||||||
|
Unter **Kommunikation → Chat** erscheinen diese Unterräume ebenfalls in der Raumgruppe **Zusammenarbeit**, einschließlich instanzübergreifender Räume und Ungelesen-Zähler. Einladungen lassen sich dort direkt annehmen oder ablehnen; nach der Annahme öffnet sich der Raum. Nachrichten, Teilnehmer und ausdrücklich freigegebene Projektinformationen werden direkt im normalen Chat angezeigt. **Details und Freigaben** öffnet die bestehende Verwaltungsansicht für den ausgewählten Raum; **Im Chat öffnen** führt zurück. Sichtbar sind ausschließlich Räume mit eigener Teilnahme, keine lediglich verwaltbaren Räume. Interne Chaträume und Unterräume behalten ihre getrennten Berechtigungsprüfungen.
|
||||||
|
|
||||||
Einladungen unterstützen eigene Mitarbeiter, persönliche FEDEO-Kontaktadressen und Gäste ohne FEDEO-Konto. Eine Kontaktadresse bezeichnet immer genau einen Benutzer in einem Heimatmandanten. Sie lässt sich in der Zusammenarbeit kopieren. Kontakte anderer Mandanten derselben Instanz benötigen keinen Server-Server-Transport. Fremde Instanzen müssen auf beiden Seiten im jeweiligen Mandanten unter **Partnerinstanzen** freigegeben sein. Beim Empfänger erscheint ein Angebot, das ausdrücklich angenommen oder abgelehnt wird.
|
Einladungen unterstützen eigene Mitarbeiter, persönliche FEDEO-Kontaktadressen und Gäste ohne FEDEO-Konto. Eine Kontaktadresse bezeichnet immer genau einen Benutzer in einem Heimatmandanten. Sie lässt sich in der Zusammenarbeit kopieren. Kontakte anderer Mandanten derselben Instanz benötigen keinen Server-Server-Transport. Fremde Instanzen müssen auf beiden Seiten im jeweiligen Mandanten unter **Partnerinstanzen** freigegeben sein. Beim Empfänger erscheint ein Angebot, das ausdrücklich angenommen oder abgelehnt wird.
|
||||||
|
|
||||||
Gäste erhalten ausschließlich einen Projekt-Unterraum. Der Link öffnet `/guest`; ein achtstelliger, zehn Minuten gültiger Einmalcode wird an die eingeladene Adresse geschickt. Nach fünf falschen Versuchen ist der Code gesperrt. Die eigene Gastsitzung gilt zwölf Stunden und wird nicht als reguläres FEDEO-Token akzeptiert. Für spätere Anmeldungen bietet die Gastoberfläche eine als Lesezeichen speicherbare Adresse mit öffentlicher Workspace-ID an. Ohne gewähltes Ablaufdatum gilt die Teilnahme eines Gasts 30 Tage. Der Einladungslink allein erlaubt keinen Inhaltszugriff.
|
Gäste erhalten ausschließlich einen Projekt-Unterraum. Der Link öffnet `/guest`; ein achtstelliger, zehn Minuten gültiger Einmalcode wird an die eingeladene Adresse geschickt. Nach fünf falschen Versuchen ist der Code gesperrt. Die eigene Gastsitzung gilt zwölf Stunden und wird nicht als reguläres FEDEO-Token akzeptiert. Für spätere Anmeldungen bietet die Gastoberfläche eine als Lesezeichen speicherbare Adresse mit öffentlicher Workspace-ID an. Ohne gewähltes Ablaufdatum gilt die Teilnahme eines Gasts 30 Tage. Der Einladungslink allein erlaubt keinen Inhaltszugriff.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' } })
|
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } })
|
||||||
const emit = defineEmits(['changed', 'unauthorized'])
|
const emit = defineEmits(['changed', 'unauthorized'])
|
||||||
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), error = ref('')
|
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), error = ref('')
|
||||||
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
||||||
@@ -95,9 +95,9 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex min-h-[32rem] min-w-0 flex-col rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">
|
<section class="flex min-w-0 flex-col bg-white dark:bg-gray-900" :class="embedded ? 'min-h-0 overflow-hidden' : 'min-h-[32rem] rounded-xl border border-gray-200 dark:border-gray-700'">
|
||||||
<header class="border-b border-gray-200 p-4 dark:border-gray-700">
|
<header class="border-b border-gray-200 p-4 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2>
|
<div class="flex flex-wrap items-center justify-between gap-2"><h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2><slot name="actions" /></div>
|
||||||
<p v-if="room?.topic" class="mt-1 text-sm text-gray-500">{{ room.topic }}</p>
|
<p v-if="room?.topic" class="mt-1 text-sm text-gray-500">{{ room.topic }}</p>
|
||||||
<p v-if="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
|
<p v-if="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -110,7 +110,7 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
|||||||
<p v-if="room.overview.date">Termin: {{ room.overview.date }}</p>
|
<p v-if="room.overview.date">Termin: {{ room.overview.date }}</p>
|
||||||
<p v-if="room.overview.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
|
<p v-if="room.overview.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div ref="viewport" class="max-h-[60vh] min-h-[18rem] flex-1 space-y-3 overflow-y-auto p-4" aria-live="polite">
|
<div ref="viewport" class="flex-1 space-y-3 overflow-y-auto p-4" :class="embedded ? 'min-h-0' : 'max-h-[60vh] min-h-[18rem]'" aria-live="polite">
|
||||||
<p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p>
|
<p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p>
|
||||||
<button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button>
|
<button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button>
|
||||||
<p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p>
|
<p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p>
|
||||||
|
|||||||
@@ -1,219 +1,231 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { $api } = useNuxtApp()
|
const { $api } = useNuxtApp()
|
||||||
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const baseRooms = ref([])
|
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
|
||||||
const projectRooms = ref([])
|
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
|
||||||
const directRooms = ref([])
|
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref('')
|
||||||
const unreadRooms = ref({})
|
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
|
||||||
const messages = ref([])
|
const activeRoomKey = ref(requestedRoom())
|
||||||
const members = ref([])
|
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
|
||||||
const draft = ref("")
|
const showRoomForm = ref(false), messagesViewport = ref(null)
|
||||||
const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
|
const roomForm = reactive({ name: '', key: '', topic: '' })
|
||||||
const loading = ref(true)
|
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`)
|
||||||
const roomLoading = ref(false)
|
const hasSession = computed(() => !!auth.user && !!auth.activeTenant)
|
||||||
const sending = ref(false)
|
let pollTimer, listTimer, pollBusy = false, mounted = false, disposed = false
|
||||||
const creating = ref(false)
|
let scopeVersion = 0, roomVersion = 0, collaborationRequest = 0, nativeListRequest = 0, syncAfterId = 0
|
||||||
const showRoomForm = ref(false)
|
const currentScope = version => !disposed && version === scopeVersion
|
||||||
const messagesViewport = ref(null)
|
const selection = () => ({ scope: scopeVersion, version: roomVersion, key: activeRoomKey.value })
|
||||||
const roomForm = reactive({ name: "", key: "", topic: "" })
|
const currentSelection = request => currentScope(request.scope) && request.version === roomVersion && request.key === activeRoomKey.value
|
||||||
let pollTimer = null
|
const errorMessage = error => error?.data?.message || error?.data?.error || 'Die Anfrage konnte nicht ausgeführt werden.'
|
||||||
let pollBusy = false
|
// Colons cannot occur in normalized internal room keys, so both room types stay unambiguous.
|
||||||
let syncAfterId = 0
|
const collaborationKey = id => `collaboration:${id}`
|
||||||
|
const nativeEndpoint = (key, suffix = '') => `/api/communication/chat/rooms/${encodeURIComponent(key)}${suffix}`
|
||||||
const normalizeRoomKey = (value) => String(value || "")
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize("NFKD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.replace(/ß/g, "ss")
|
|
||||||
.replace(/[^a-z0-9._=-]+/g, "_")
|
|
||||||
.replace(/_+/g, "_")
|
|
||||||
.replace(/^[._=-]+|[._=-]+$/g, "")
|
|
||||||
|
|
||||||
|
const normalizeRoomKey = value => String(value || '')
|
||||||
|
.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss')
|
||||||
|
.replace(/[^a-z0-9._=-]+/g, '_').replace(/_+/g, '_').replace(/^[._=-]+|[._=-]+$/g, '')
|
||||||
const roomKeyPreview = computed(() => normalizeRoomKey(roomForm.key || roomForm.name))
|
const roomKeyPreview = computed(() => normalizeRoomKey(roomForm.key || roomForm.name))
|
||||||
const decoratedRooms = computed(() => [
|
const decoratedRooms = computed(() => [
|
||||||
...baseRooms.value.map((room) => ({ ...room, group: "Räume", icon: "i-heroicons-chat-bubble-left-right" })),
|
...[
|
||||||
...projectRooms.value.map((room) => ({ ...room, group: "Projekte", icon: "i-heroicons-briefcase" })),
|
...baseRooms.value.map(room => ({ ...room, group: 'Räume', icon: 'i-heroicons-chat-bubble-left-right' })),
|
||||||
...directRooms.value.map((room) => ({ ...room, group: "Direktnachrichten", icon: "i-heroicons-user-circle" }))
|
...projectRooms.value.map(room => ({ ...room, group: 'Projekte', icon: 'i-heroicons-briefcase' })),
|
||||||
].map((room) => ({ ...room, unread: unreadRooms.value[room.key]?.count || 0 })))
|
...directRooms.value.map(room => ({ ...room, group: 'Direktnachrichten', icon: 'i-heroicons-user-circle' }))
|
||||||
const groupedRooms = computed(() => ["Räume", "Projekte", "Direktnachrichten"]
|
].map(room => ({ ...room, source: 'internal', unread: unreadRooms.value[room.key]?.count || 0 })),
|
||||||
.map((label) => ({ label, rooms: decoratedRooms.value.filter((room) => room.group === label) }))
|
...collaborationRooms.value.map(room => ({ ...room, key: collaborationKey(room.id), source: 'collaboration', exists: true,
|
||||||
.filter((group) => group.rooms.length))
|
group: 'Zusammenarbeit', icon: 'i-heroicons-user-group', unread: room.unread || 0 }))
|
||||||
const activeRoom = computed(() => decoratedRooms.value.find((room) => room.key === activeRoomKey.value) || null)
|
])
|
||||||
const roomEndpoint = computed(() => `/api/communication/chat/rooms/${encodeURIComponent(activeRoomKey.value)}`)
|
const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit']
|
||||||
|
.map(label => ({ label, rooms: decoratedRooms.value.filter(room => room.group === label) }))
|
||||||
|
.filter(group => group.rooms.length))
|
||||||
|
const activeRoom = computed(() => decoratedRooms.value.find(room => room.key === activeRoomKey.value) || null)
|
||||||
|
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
|
||||||
|
const isInternal = computed(() => activeRoom.value?.source === 'internal')
|
||||||
|
|
||||||
const mergeMessages = (incoming) => {
|
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; syncAfterId = 0; roomLoading.value = false; sending.value = false }
|
||||||
const byId = new Map(messages.value.map((message) => [message.id, message]))
|
function mergeMessages(incoming) {
|
||||||
|
const byId = new Map(messages.value.map(message => [message.id, message]))
|
||||||
for (const message of incoming || []) byId.set(message.id, message)
|
for (const message of incoming || []) byId.set(message.id, message)
|
||||||
messages.value = Array.from(byId.values()).sort((a, b) => Number(a.id) - Number(b.id))
|
messages.value = [...byId.values()].sort((a, b) => Number(a.id) - Number(b.id))
|
||||||
syncAfterId = Number(messages.value.at(-1)?.id || syncAfterId)
|
syncAfterId = Number(messages.value.at(-1)?.id || 0)
|
||||||
}
|
}
|
||||||
|
const scrollToBottom = async request => {
|
||||||
const scrollToBottom = async () => {
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight
|
if (currentSelection(request) && messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadUnread = async () => {
|
async function loadUnread(scope = scopeVersion) {
|
||||||
|
if (!hasSession.value || !currentScope(scope)) return
|
||||||
try {
|
try {
|
||||||
unreadRooms.value = (await $api("/api/communication/chat/unread")).rooms || {}
|
const result = await $api('/api/communication/chat/unread')
|
||||||
} catch {
|
if (currentScope(scope)) unreadRooms.value = result.rooms || {}
|
||||||
unreadRooms.value = {}
|
} catch { /* A count failure must not interrupt an open conversation. */ }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
async function loadRoomLists(scope = scopeVersion) {
|
||||||
const loadRoomLists = async () => {
|
if (!hasSession.value || !currentScope(scope)) return
|
||||||
const [rooms, projects, directs] = await Promise.all([
|
const request = ++nativeListRequest
|
||||||
$api("/api/communication/chat/rooms"),
|
|
||||||
$api("/api/communication/chat/project-rooms"),
|
|
||||||
$api("/api/communication/chat/direct-rooms")
|
|
||||||
])
|
|
||||||
baseRooms.value = rooms.rooms || []
|
|
||||||
projectRooms.value = projects.rooms || []
|
|
||||||
directRooms.value = directs.rooms || []
|
|
||||||
}
|
|
||||||
|
|
||||||
const markRead = async () => {
|
|
||||||
if (!activeRoom.value?.exists) return
|
|
||||||
try {
|
try {
|
||||||
await $api(`${roomEndpoint.value}/read`, {
|
const [rooms, projects, directs] = await Promise.all([
|
||||||
method: "POST",
|
$api('/api/communication/chat/rooms'), $api('/api/communication/chat/project-rooms'), $api('/api/communication/chat/direct-rooms')
|
||||||
body: { messageId: Number(messages.value.at(-1)?.id || 0) }
|
|
||||||
})
|
|
||||||
unreadRooms.value = { ...unreadRooms.value, [activeRoomKey.value]: { count: 0, mentions: 0 } }
|
|
||||||
} catch {
|
|
||||||
// Der Lesestatus darf die Unterhaltung nicht blockieren.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadActiveRoom = async () => {
|
|
||||||
if (!activeRoom.value?.exists) return
|
|
||||||
roomLoading.value = true
|
|
||||||
syncAfterId = 0
|
|
||||||
try {
|
|
||||||
const [messageResult, memberResult] = await Promise.all([
|
|
||||||
$api(`${roomEndpoint.value}/messages`),
|
|
||||||
$api(`${roomEndpoint.value}/members`)
|
|
||||||
])
|
])
|
||||||
messages.value = []
|
if (!currentScope(scope) || request !== nativeListRequest) return
|
||||||
mergeMessages(messageResult.messages || [])
|
baseRooms.value = rooms.rooms || []; projectRooms.value = projects.rooms || []; directRooms.value = directs.rooms || []
|
||||||
members.value = memberResult.members || []
|
listError.value = ''
|
||||||
await markRead()
|
} catch (error) { if (currentScope(scope) && request === nativeListRequest) listError.value = errorMessage(error) }
|
||||||
await scrollToBottom()
|
|
||||||
} catch (error) {
|
|
||||||
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geladen werden", color: "error" })
|
|
||||||
} finally {
|
|
||||||
roomLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
async function loadCollaboration(scope = scopeVersion) {
|
||||||
const provisionRoom = async (room) => {
|
if (!hasSession.value || !currentScope(scope)) return
|
||||||
const endpoint = room.type === "project"
|
const request = ++collaborationRequest
|
||||||
? `/api/communication/chat/project-rooms/${room.projectId}/provision`
|
|
||||||
: `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`
|
|
||||||
const created = await $api(endpoint, { method: "POST" })
|
|
||||||
await loadRoomLists()
|
|
||||||
activeRoomKey.value = created.key
|
|
||||||
await router.replace({ query: { ...route.query, room: created.key } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectRoom = async (room) => {
|
|
||||||
try {
|
try {
|
||||||
if (!room.exists) await provisionRoom(room)
|
const result = await $api('/api/collaboration')
|
||||||
else {
|
if (!currentScope(scope) || request !== collaborationRequest) return
|
||||||
activeRoomKey.value = room.key
|
// Managed rooms alone never grant chat access. Only explicit memberships enter this list.
|
||||||
await router.replace({ query: { ...route.query, room: room.key } })
|
collaborationRooms.value = result.rooms || []; invitations.value = result.invitations || []
|
||||||
}
|
collaborationError.value = ''
|
||||||
messages.value = []
|
|
||||||
members.value = []
|
|
||||||
await loadActiveRoom()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geöffnet werden", color: "error" })
|
if (!currentScope(scope) || request !== collaborationRequest) return
|
||||||
|
collaborationError.value = errorMessage(error)
|
||||||
|
if ([401, 403].includes(error?.statusCode || error?.status)) { collaborationRooms.value = []; invitations.value = [] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function refreshLists() {
|
||||||
|
const scope = scopeVersion
|
||||||
|
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)])
|
||||||
|
}
|
||||||
|
|
||||||
const createRoom = async () => {
|
async function markRead(request) {
|
||||||
if (!roomForm.name.trim()) return
|
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return
|
||||||
|
const messageId = Number(messages.value.at(-1)?.id || 0)
|
||||||
|
try {
|
||||||
|
await $api(nativeEndpoint(request.key, '/read'), { method: 'POST', body: { messageId } })
|
||||||
|
if (currentSelection(request)) unreadRooms.value = { ...unreadRooms.value, [request.key]: { count: 0, mentions: 0 } }
|
||||||
|
} catch { /* Read receipts must not interrupt the conversation. */ }
|
||||||
|
}
|
||||||
|
async function loadActiveRoom(request = selection()) {
|
||||||
|
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return
|
||||||
|
roomLoading.value = true
|
||||||
|
try {
|
||||||
|
const [result, people] = await Promise.all([
|
||||||
|
$api(nativeEndpoint(request.key, '/messages')), $api(nativeEndpoint(request.key, '/members'))
|
||||||
|
])
|
||||||
|
if (!currentSelection(request)) return
|
||||||
|
messages.value = []; mergeMessages(result.messages); members.value = people.members || []
|
||||||
|
await markRead(request); await scrollToBottom(request)
|
||||||
|
} catch (error) {
|
||||||
|
if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' })
|
||||||
|
} finally { if (currentSelection(request)) roomLoading.value = false }
|
||||||
|
}
|
||||||
|
async function selectRoom(room) {
|
||||||
|
++roomVersion; activeRoomKey.value = room.key; clearMessages()
|
||||||
|
let request = selection()
|
||||||
|
try {
|
||||||
|
if (room.source !== 'collaboration' && !room.exists) {
|
||||||
|
const endpoint = room.type === 'project'
|
||||||
|
? `/api/communication/chat/project-rooms/${room.projectId}/provision`
|
||||||
|
: `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`
|
||||||
|
const created = await $api(endpoint, { method: 'POST' })
|
||||||
|
if (!currentSelection(request)) return
|
||||||
|
await loadRoomLists(request.scope)
|
||||||
|
if (!currentSelection(request)) return
|
||||||
|
activeRoomKey.value = created.key; request = selection()
|
||||||
|
}
|
||||||
|
await router.replace({ query: { ...route.query, room: request.key } })
|
||||||
|
if (currentSelection(request)) await loadActiveRoom(request)
|
||||||
|
} catch (error) { if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||||
|
}
|
||||||
|
async function createRoom() {
|
||||||
|
if (!roomForm.name.trim() || creating.value) return
|
||||||
|
const scope = scopeVersion
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
const created = await $api("/api/communication/chat/rooms", {
|
const created = await $api('/api/communication/chat/rooms', {
|
||||||
method: "POST",
|
method: 'POST', body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
|
||||||
body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
|
|
||||||
})
|
})
|
||||||
Object.assign(roomForm, { name: "", key: "", topic: "" })
|
if (!currentScope(scope)) return
|
||||||
showRoomForm.value = false
|
Object.assign(roomForm, { name: '', key: '', topic: '' }); showRoomForm.value = false
|
||||||
await loadRoomLists()
|
await loadRoomLists(scope)
|
||||||
await selectRoom(created)
|
if (currentScope(scope)) await selectRoom(created)
|
||||||
} catch (error) {
|
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||||
toast.add({ title: error?.data?.error || "Chatraum konnte nicht erstellt werden", color: "error" })
|
finally { if (currentScope(scope)) creating.value = false }
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
async function respondToInvitation(id, accept) {
|
||||||
const sendMessage = async () => {
|
if (invitationBusy.value) return
|
||||||
const text = draft.value.trim()
|
const scope = scopeVersion
|
||||||
if (!text || sending.value || !activeRoom.value?.exists) return
|
invitationBusy.value = id
|
||||||
sending.value = true
|
|
||||||
draft.value = ""
|
|
||||||
try {
|
try {
|
||||||
const message = await $api(`${roomEndpoint.value}/messages`, { method: "POST", body: { text } })
|
const result = await $api(`/api/collaboration/invitations/${encodeURIComponent(id)}/${accept ? 'accept' : 'decline'}`, { method: 'POST' })
|
||||||
mergeMessages([message])
|
if (!currentScope(scope)) return
|
||||||
await markRead()
|
await loadCollaboration(scope)
|
||||||
await scrollToBottom()
|
if (!currentScope(scope)) return
|
||||||
} catch (error) {
|
const room = decoratedRooms.value.find(room => room.key === collaborationKey(result.roomId))
|
||||||
draft.value = text
|
if (accept && room) await selectRoom(room)
|
||||||
toast.add({ title: error?.data?.error || "Nachricht konnte nicht gesendet werden", color: "error" })
|
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||||
} finally {
|
finally { if (currentScope(scope)) invitationBusy.value = '' }
|
||||||
sending.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
async function sendMessage() {
|
||||||
const pollMessages = async () => {
|
const text = draft.value.trim(), request = selection()
|
||||||
if (pollBusy || document.hidden || !activeRoom.value?.exists) return
|
if (!text || sending.value || !isInternal.value || !activeRoom.value?.exists) return
|
||||||
|
sending.value = true; draft.value = ''
|
||||||
|
try {
|
||||||
|
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text } })
|
||||||
|
if (!currentSelection(request)) return
|
||||||
|
mergeMessages([message]); await markRead(request); await scrollToBottom(request)
|
||||||
|
} catch (error) {
|
||||||
|
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||||
|
} finally { if (currentSelection(request)) sending.value = false }
|
||||||
|
}
|
||||||
|
async function pollMessages() {
|
||||||
|
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return
|
||||||
|
const request = selection()
|
||||||
pollBusy = true
|
pollBusy = true
|
||||||
try {
|
try {
|
||||||
const result = await $api(`${roomEndpoint.value}/sync?afterId=${syncAfterId}`)
|
const result = await $api(nativeEndpoint(request.key, `/sync?afterId=${syncAfterId}`))
|
||||||
if (result.messages?.length) {
|
if (!currentSelection(request)) return
|
||||||
mergeMessages(result.messages)
|
if (result.messages?.length) { mergeMessages(result.messages); await markRead(request); await scrollToBottom(request) }
|
||||||
await markRead()
|
await loadUnread(request.scope)
|
||||||
await scrollToBottom()
|
|
||||||
}
|
|
||||||
await loadUnread()
|
|
||||||
} catch {
|
|
||||||
// Beim nächsten Intervall erneut versuchen.
|
|
||||||
} finally {
|
|
||||||
pollBusy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatMessageTime = (timestamp) => new Intl.DateTimeFormat("de-DE", {
|
|
||||||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"
|
|
||||||
}).format(new Date(timestamp))
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
await Promise.all([loadRoomLists(), loadUnread()])
|
|
||||||
if (!activeRoom.value) activeRoomKey.value = "allgemein"
|
|
||||||
await loadActiveRoom()
|
|
||||||
pollTimer = window.setInterval(pollMessages, 2500)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({ title: error?.data?.error || "Chat konnte nicht geladen werden", color: "error" })
|
if (currentSelection(request) && [401, 403, 404, 410].includes(error?.statusCode || error?.status)) {
|
||||||
} finally {
|
++roomVersion; clearMessages(); void loadRoomLists(request.scope)
|
||||||
loading.value = false
|
}
|
||||||
}
|
} finally { pollBusy = false }
|
||||||
})
|
}
|
||||||
|
const formatMessageTime = timestamp => new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(timestamp))
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
async function initialize() {
|
||||||
if (pollTimer) window.clearInterval(pollTimer)
|
const scope = ++scopeVersion
|
||||||
|
++roomVersion; clearMessages()
|
||||||
|
baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = []
|
||||||
|
unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false
|
||||||
|
showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' })
|
||||||
|
activeRoomKey.value = requestedRoom(); loading.value = true
|
||||||
|
await refreshLists()
|
||||||
|
if (!currentScope(scope)) return
|
||||||
|
loading.value = false
|
||||||
|
const room = activeRoom.value || (activeRoomKey.value === 'allgemein' ? decoratedRooms.value[0] : null)
|
||||||
|
if (room) await selectRoom(room)
|
||||||
|
}
|
||||||
|
watch(session, () => { if (mounted) void initialize() }, { flush: 'sync' })
|
||||||
|
watch(() => route.query.room, () => {
|
||||||
|
if (!mounted || loading.value || requestedRoom() === activeRoomKey.value) return
|
||||||
|
const room = decoratedRooms.value.find(room => room.key === requestedRoom())
|
||||||
|
if (room) void selectRoom(room)
|
||||||
|
else { ++roomVersion; activeRoomKey.value = requestedRoom(); clearMessages() }
|
||||||
})
|
})
|
||||||
|
onMounted(() => {
|
||||||
|
mounted = true; void initialize()
|
||||||
|
pollTimer = window.setInterval(pollMessages, 2500)
|
||||||
|
listTimer = window.setInterval(() => { if (!document.hidden && !loading.value) void refreshLists() }, 15000)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInterval(pollTimer); clearInterval(listTimer) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-0 flex-1 bg-gray-50 p-4 sm:p-6">
|
<div class="flex min-h-0 flex-1 bg-gray-50 p-4 sm:p-6">
|
||||||
<div class="mx-auto grid h-[calc(100vh-8rem)] w-full max-w-7xl grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm lg:grid-cols-[19rem_minmax(0,1fr)_15rem]">
|
<div class="mx-auto grid h-[calc(100vh-8rem)] w-full max-w-7xl grid-cols-1 grid-rows-[minmax(8rem,30%)_minmax(0,1fr)] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm lg:grid-rows-1"
|
||||||
|
:class="isCollaboration ? 'lg:grid-cols-[19rem_minmax(0,1fr)]' : 'lg:grid-cols-[19rem_minmax(0,1fr)_15rem]'">
|
||||||
<aside class="min-h-0 overflow-y-auto border-r border-gray-200 p-4">
|
<aside class="min-h-0 overflow-y-auto border-r border-gray-200 p-4">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -233,6 +245,22 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<UButton to="/communication/collaboration" icon="i-heroicons-user-group" color="neutral" variant="outline" size="xs" class="mb-4 w-full justify-center">Unterräume und Freigaben</UButton>
|
||||||
|
<p v-if="listError" role="alert" class="mb-3 text-xs text-red-700">{{ listError }} <button class="underline" @click="loadRoomLists()">Erneut laden</button></p>
|
||||||
|
<p v-if="collaborationError" role="alert" class="mb-3 text-xs text-red-700">Zusammenarbeit: {{ collaborationError }} <button class="underline" @click="loadCollaboration()">Erneut laden</button></p>
|
||||||
|
<section v-if="invitations.length" class="mb-5 space-y-2" aria-label="Einladungen">
|
||||||
|
<h2 class="px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Einladungen</h2>
|
||||||
|
<div v-for="invitation in invitations" :key="invitation.id" class="rounded-lg border border-primary-200 bg-primary-50 p-3">
|
||||||
|
<p class="text-sm font-medium">{{ invitation.name }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ invitation.ownerName }} · {{ invitation.role === 'reader' ? 'Nur lesen' : 'Lesen und schreiben' }}</p>
|
||||||
|
<p v-if="invitation.overviewAllowed" class="mt-1 text-xs text-gray-500">Projektübersicht freigegeben</p>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<UButton size="xs" :disabled="!!invitationBusy" @click="respondToInvitation(invitation.id, true)">Annehmen</UButton>
|
||||||
|
<UButton size="xs" variant="ghost" color="neutral" :disabled="!!invitationBusy" @click="respondToInvitation(invitation.id, false)">Ablehnen</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div v-if="loading" class="py-8 text-center text-sm text-gray-500">Chat wird geladen …</div>
|
<div v-if="loading" class="py-8 text-center text-sm text-gray-500">Chat wird geladen …</div>
|
||||||
<div v-else class="space-y-5">
|
<div v-else class="space-y-5">
|
||||||
<section v-for="group in groupedRooms" :key="group.label">
|
<section v-for="group in groupedRooms" :key="group.label">
|
||||||
@@ -248,7 +276,7 @@ onBeforeUnmount(() => {
|
|||||||
<UIcon :name="room.icon" class="size-5 shrink-0" />
|
<UIcon :name="room.icon" class="size-5 shrink-0" />
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
|
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
|
||||||
<span class="block truncate text-xs text-gray-400">{{ room.topic }}</span>
|
<span class="block truncate text-xs text-gray-400">{{ room.source === 'collaboration' ? room.ownerName || 'Projekt-Unterraum' : room.topic }}{{ room.remote ? ' · Extern' : '' }}{{ room.status === 'archived' ? ' · Archiviert' : '' }}</span>
|
||||||
</span>
|
</span>
|
||||||
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
|
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
|
||||||
</button>
|
</button>
|
||||||
@@ -256,7 +284,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-col">
|
<CollaborationRoom v-if="isCollaboration" :key="`${session}:${activeRoom.id}`" :room-id="activeRoom.id" :api="$api" embedded @changed="loadCollaboration()" @unauthorized="loadCollaboration()">
|
||||||
|
<template #actions><UButton :to="{ path: '/communication/collaboration', query: { room: activeRoom.id } }" color="neutral" variant="outline" size="xs">Details und Freigaben</UButton></template>
|
||||||
|
</CollaborationRoom>
|
||||||
|
<main v-else class="flex min-h-0 flex-col">
|
||||||
<header class="border-b border-gray-200 px-5 py-4">
|
<header class="border-b border-gray-200 px-5 py-4">
|
||||||
<h2 class="font-semibold text-gray-900">{{ activeRoom?.name || "Chat" }}</h2>
|
<h2 class="font-semibold text-gray-900">{{ activeRoom?.name || "Chat" }}</h2>
|
||||||
<p class="truncate text-sm text-gray-500">{{ activeRoom?.topic || "Wähle links einen Chatraum aus." }}</p>
|
<p class="truncate text-sm text-gray-500">{{ activeRoom?.topic || "Wähle links einen Chatraum aus." }}</p>
|
||||||
@@ -264,6 +295,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div ref="messagesViewport" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
<div ref="messagesViewport" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
||||||
<div v-if="roomLoading" class="py-10 text-center text-sm text-gray-500">Nachrichten werden geladen …</div>
|
<div v-if="roomLoading" class="py-10 text-center text-sm text-gray-500">Nachrichten werden geladen …</div>
|
||||||
|
<div v-else-if="!activeRoom" class="py-10 text-center text-sm text-gray-500">Dieser Chatraum ist nicht verfügbar. Wähle links einen anderen Raum.</div>
|
||||||
<div v-else-if="!messages.length" class="flex h-full flex-col items-center justify-center text-center text-gray-400">
|
<div v-else-if="!messages.length" class="flex h-full flex-col items-center justify-center text-center text-gray-400">
|
||||||
<UIcon name="i-heroicons-chat-bubble-oval-left-ellipsis" class="mb-3 size-10" />
|
<UIcon name="i-heroicons-chat-bubble-oval-left-ellipsis" class="mb-3 size-10" />
|
||||||
<p class="font-medium">Noch keine Nachrichten</p>
|
<p class="font-medium">Noch keine Nachrichten</p>
|
||||||
@@ -286,7 +318,7 @@ onBeforeUnmount(() => {
|
|||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
|
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
|
||||||
<h2 class="mb-3 text-sm font-semibold text-gray-900">Teilnehmer</h2>
|
<h2 class="mb-3 text-sm font-semibold text-gray-900">Teilnehmer</h2>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-for="member in members" :key="member.userId" class="flex items-center gap-3">
|
<div v-for="member in members" :key="member.userId" class="flex items-center gap-3">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
const { $api } = useNuxtApp()
|
const { $api } = useNuxtApp()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const state = ref({ rooms: [], invitations: [], managedRooms: [], projects: [], canManage: false, contact: '' })
|
const state = ref({ rooms: [], invitations: [], managedRooms: [], projects: [], canManage: false, contact: '' })
|
||||||
const contacts = ref([]), selected = ref(''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false)
|
const contacts = ref([]), selected = ref(typeof route.query.room === 'string' ? route.query.room : ''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false)
|
||||||
const management = ref({ members: [], invitations: [] }), inviteResult = ref(null)
|
const management = ref({ members: [], invitations: [] }), inviteResult = ref(null)
|
||||||
const create = reactive({ name: '', topic: '', projectId: String(route.query.project || '') })
|
const create = reactive({ name: '', topic: '', projectId: String(route.query.project || '') })
|
||||||
const invite = reactive({ kind: 'user', userId: '', name: '', email: '', contact: '', role: 'participant', overviewAllowed: false, expiresAt: '', sendEmail: true })
|
const invite = reactive({ kind: 'user', userId: '', name: '', email: '', contact: '', role: 'participant', overviewAllowed: false, expiresAt: '', sendEmail: true })
|
||||||
@@ -73,6 +73,7 @@ async function savePartner(origin, active = true) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
watch(selected, () => { showManagement.value = false; inviteResult.value = null; management.value = { members: [], invitations: [] } })
|
watch(selected, () => { showManagement.value = false; inviteResult.value = null; management.value = { members: [], invitations: [] } })
|
||||||
|
watch(() => route.query.room, id => { if (typeof id === 'string') selected.value = id })
|
||||||
let timer
|
let timer
|
||||||
onMounted(async () => { await refresh(); timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000) })
|
onMounted(async () => { await refresh(); timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000) })
|
||||||
onBeforeUnmount(() => clearInterval(timer))
|
onBeforeUnmount(() => clearInterval(timer))
|
||||||
@@ -120,7 +121,10 @@ onBeforeUnmount(() => clearInterval(timer))
|
|||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="min-w-0 space-y-4">
|
<div class="min-w-0 space-y-4">
|
||||||
<UButton v-if="selectedManaged" variant="outline" :disabled="busy" @click="run(async () => { await loadManagement(); showManagement = !showManagement })">Teilnehmer und Freigaben verwalten</UButton>
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton v-if="readable" :to="{ path: '/communication/chat', query: { room: `collaboration:${selected}` } }" variant="outline">Im Chat öffnen</UButton>
|
||||||
|
<UButton v-if="selectedManaged" variant="outline" :disabled="busy" @click="run(async () => { await loadManagement(); showManagement = !showManagement })">Teilnehmer und Freigaben verwalten</UButton>
|
||||||
|
</div>
|
||||||
<section v-if="showManagement && selectedManaged" class="space-y-5 rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
<section v-if="showManagement && selectedManaged" class="space-y-5 rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||||
<form class="space-y-3" @submit.prevent="invitePerson">
|
<form class="space-y-3" @submit.prevent="invitePerson">
|
||||||
<h2 class="font-semibold">Person in diesen Unterraum einladen</h2>
|
<h2 class="font-semibold">Person in diesen Unterraum einladen</h2>
|
||||||
|
|||||||
239
frontend/tests/chat-collaboration.test.cjs
Normal file
239
frontend/tests/chat-collaboration.test.cjs
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const vue = require('vue')
|
||||||
|
|
||||||
|
const settle = () => new Promise(setImmediate)
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve
|
||||||
|
const promise = new Promise(done => { resolve = done })
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
const source = fs.readFileSync(`${__dirname}/../pages/communication/chat.vue`, 'utf8').match(/<script setup>([\s\S]*?)<\/script>/)[1]
|
||||||
|
const nativeRoom = { key: 'allgemein', name: 'Allgemein', exists: true, type: 'general' }
|
||||||
|
const sharedRoom = { id: 'shared-room', name: 'Partnerchat', remote: true, unread: 3 }
|
||||||
|
|
||||||
|
function fixture(override = () => undefined) {
|
||||||
|
return async (url, options) => {
|
||||||
|
const result = override(url, options)
|
||||||
|
if (result !== undefined) return result
|
||||||
|
if (url === '/api/collaboration') return { rooms: [sharedRoom], invitations: [], managedRooms: [{ id: 'admin-only' }] }
|
||||||
|
if (url === '/api/communication/chat/rooms') return { rooms: [nativeRoom] }
|
||||||
|
if (url.endsWith('/project-rooms') || url.endsWith('/direct-rooms')) return { rooms: [] }
|
||||||
|
if (url.endsWith('/unread')) return { rooms: { allgemein: { count: 2 } } }
|
||||||
|
if (url.endsWith('/members')) return { members: [{ userId: 'member', displayName: 'Kollege' }] }
|
||||||
|
if (url.includes('/sync?')) return { messages: [] }
|
||||||
|
if (url.endsWith('/messages')) return { messages: [{ id: 1, body: 'Interne Nachricht' }] }
|
||||||
|
if (url.endsWith('/read')) return {}
|
||||||
|
throw new Error(`Unexpected request: ${url}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(api = fixture(), query = {}) {
|
||||||
|
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
|
||||||
|
const route = vue.reactive({ query })
|
||||||
|
const calls = [], toasts = [], timers = new Map()
|
||||||
|
let mount, unmount, timerId = 0
|
||||||
|
const scope = vue.effectScope()
|
||||||
|
const context = vm.createContext({
|
||||||
|
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
|
||||||
|
useToast: () => ({ add: item => toasts.push(item) }),
|
||||||
|
useAuthStore: () => auth, useRoute: () => route,
|
||||||
|
useRouter: () => ({ replace: async location => { route.query = location.query } }),
|
||||||
|
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
|
||||||
|
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
|
||||||
|
window: { setInterval: handler => { timers.set(++timerId, handler); return timerId } },
|
||||||
|
clearInterval: id => timers.delete(id), document: { hidden: false },
|
||||||
|
})
|
||||||
|
scope.run(() => vm.runInContext(source, context))
|
||||||
|
return { auth, route, calls, toasts, timers, mount, close: () => { unmount(); scope.stop() }, run: code => vm.runInContext(code, context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
|
||||||
|
const ui = setup()
|
||||||
|
t.after(ui.close)
|
||||||
|
await ui.run('refreshLists()')
|
||||||
|
assert.equal(ui.run('decoratedRooms.value.length'), 2)
|
||||||
|
assert.equal(ui.run('decoratedRooms.value.find(r => r.source === "collaboration").key'), 'collaboration:shared-room')
|
||||||
|
assert.equal(ui.run('groupedRooms.value.at(-1).label'), 'Zusammenarbeit')
|
||||||
|
assert.equal(ui.run('decoratedRooms.value.at(-1).unread'), 3)
|
||||||
|
assert.equal(ui.run('decoratedRooms.value[0].unread'), 2)
|
||||||
|
assert.equal(ui.run('decoratedRooms.value.some(r => r.id === "admin-only")'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opening a collaboration deep link never provisions, reads or sends through native chat APIs', async t => {
|
||||||
|
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('isCollaboration.value'), true)
|
||||||
|
assert.equal(ui.route.query.room, 'collaboration:shared-room')
|
||||||
|
ui.run('draft.value = "Partnernachricht"')
|
||||||
|
await ui.run('pollMessages()')
|
||||||
|
await ui.run('sendMessage()')
|
||||||
|
assert.equal(ui.calls.some(({ url }) => url.startsWith('/api/communication/chat/rooms/')), false)
|
||||||
|
ui.route.query = { room: 'allgemein' }
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('isInternal.value'), true)
|
||||||
|
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
|
||||||
|
ui.route.query = { room: 'collaboration:shared-room' }
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('isCollaboration.value'), true)
|
||||||
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepting an invitation opens the returned local binding and clears the invitation', async t => {
|
||||||
|
let accepted = false
|
||||||
|
const ui = setup(fixture(url => {
|
||||||
|
if (url === '/api/collaboration/invitations/invite/accept') { accepted = true; return { roomId: sharedRoom.id } }
|
||||||
|
if (url === '/api/collaboration') return { rooms: accepted ? [sharedRoom] : [], invitations: accepted ? [] : [{ id: 'invite' }] }
|
||||||
|
}))
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('invitations.value.length'), 1)
|
||||||
|
await ui.run('respondToInvitation("invite", true)')
|
||||||
|
assert.equal(ui.run('invitations.value.length'), 0)
|
||||||
|
assert.equal(ui.run('isCollaboration.value'), true)
|
||||||
|
assert.equal(ui.route.query.room, 'collaboration:shared-room')
|
||||||
|
assert.equal(ui.calls.find(({ url }) => url.endsWith('/accept')).options.method, 'POST')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declining an invitation keeps the current chat open', async t => {
|
||||||
|
let declined = false
|
||||||
|
const ui = setup(fixture(url => {
|
||||||
|
if (url.endsWith('/invite/decline')) { declined = true; return { ok: true } }
|
||||||
|
if (url === '/api/collaboration') return { rooms: [], invitations: declined ? [] : [{ id: 'invite' }] }
|
||||||
|
}))
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
await ui.run('respondToInvitation("invite", false)')
|
||||||
|
assert.equal(ui.run('invitations.value.length'), 0)
|
||||||
|
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
|
||||||
|
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a late native room load cannot populate a collaboration room or mark it read', async t => {
|
||||||
|
const pending = deferred()
|
||||||
|
const ui = setup(fixture(url => url.endsWith('/allgemein/messages') ? pending.promise : undefined))
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
|
||||||
|
pending.resolve({ messages: [{ id: 99, body: 'Late private content' }] })
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
|
assert.equal(ui.run('members.value.length'), 0)
|
||||||
|
assert.equal(ui.run('roomLoading.value'), false)
|
||||||
|
assert.equal(ui.calls.some(({ url }) => url.endsWith('/read')), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('late sync and send results cannot overwrite the selected collaboration room', async t => {
|
||||||
|
const sync = deferred(), send = deferred()
|
||||||
|
const ui = setup(fixture((url, options) => {
|
||||||
|
if (url.includes('/sync?')) return sync.promise
|
||||||
|
if (url.endsWith('/messages') && options?.method === 'POST') return send.promise
|
||||||
|
}))
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
const polling = ui.run('pollMessages()')
|
||||||
|
ui.run('draft.value = "Internal draft"')
|
||||||
|
const sending = ui.run('sendMessage()')
|
||||||
|
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
|
||||||
|
const reads = ui.calls.filter(({ url }) => url.endsWith('/read')).length
|
||||||
|
sync.resolve({ messages: [{ id: 2, body: 'Late sync' }] })
|
||||||
|
send.resolve({ id: 3, body: 'Late sent message' })
|
||||||
|
await Promise.all([polling, sending])
|
||||||
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
|
assert.equal(ui.run('draft.value'), '')
|
||||||
|
assert.equal(ui.run('sending.value'), false)
|
||||||
|
assert.equal(ui.calls.filter(({ url }) => url.endsWith('/read')).length, reads)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching tenants clears visible content immediately and ignores older room lists', async t => {
|
||||||
|
const stale = deferred(), fresh = deferred()
|
||||||
|
let lists = 0
|
||||||
|
const ui = setup(fixture(url => {
|
||||||
|
if (url === '/api/collaboration') return ++lists === 1 ? stale.promise : fresh.promise
|
||||||
|
}))
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
ui.run('messages.value = [{ id: 1, body: "Previous tenant" }]; draft.value = "Private draft"')
|
||||||
|
ui.auth.activeTenant = '2'
|
||||||
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
|
assert.equal(ui.run('collaborationRooms.value.length'), 0)
|
||||||
|
assert.equal(ui.run('draft.value'), '')
|
||||||
|
assert.equal(ui.run('session.value'), 'user-1:2')
|
||||||
|
fresh.resolve({ rooms: [{ id: 'tenant-2' }], invitations: [] })
|
||||||
|
await settle()
|
||||||
|
stale.resolve({ rooms: [{ id: 'tenant-1-secret' }], invitations: [{ id: 'private-invite' }] })
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('collaborationRooms.value[0].id'), 'tenant-2')
|
||||||
|
assert.equal(ui.run('invitations.value.length'), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('collaboration and internal service failures do not block the other room type', async t => {
|
||||||
|
const ui = setup(fixture(url => {
|
||||||
|
if (url === '/api/collaboration') throw { statusCode: 503, data: { message: 'Unterräume nicht erreichbar' } }
|
||||||
|
}))
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('isInternal.value'), true)
|
||||||
|
assert.equal(ui.run('messages.value.length'), 1)
|
||||||
|
assert.equal(ui.run('collaborationError.value'), 'Unterräume nicht erreichbar')
|
||||||
|
const other = setup(fixture(url => {
|
||||||
|
if (url === '/api/communication/chat/rooms') throw { statusCode: 409, data: { message: 'Chat nicht verfügbar' } }
|
||||||
|
}), { room: 'collaboration:shared-room' })
|
||||||
|
t.after(other.close)
|
||||||
|
other.mount()
|
||||||
|
await settle()
|
||||||
|
assert.equal(other.run('isCollaboration.value'), true)
|
||||||
|
assert.equal(other.run('listError.value'), 'Chat nicht verfügbar')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('revoked membership removes the active collaboration room instead of exposing managed rooms', async t => {
|
||||||
|
let revoked = false
|
||||||
|
const ui = setup(fixture(url => {
|
||||||
|
if (url === '/api/collaboration' && revoked) return { rooms: [], invitations: [], managedRooms: [sharedRoom] }
|
||||||
|
}), { room: 'collaboration:shared-room' })
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.run('isCollaboration.value'), true)
|
||||||
|
revoked = true
|
||||||
|
await ui.run('loadCollaboration()')
|
||||||
|
assert.equal(ui.run('activeRoom.value'), null)
|
||||||
|
assert.equal(ui.run('isCollaboration.value'), false)
|
||||||
|
assert.equal(ui.run('isInternal.value'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('logout clears the room and invitations without starting requests with the previous credentials', async t => {
|
||||||
|
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
|
||||||
|
t.after(ui.close)
|
||||||
|
ui.mount()
|
||||||
|
await settle()
|
||||||
|
const calls = ui.calls.length
|
||||||
|
ui.auth.user = null
|
||||||
|
await settle()
|
||||||
|
await ui.run('refreshLists()')
|
||||||
|
assert.equal(ui.run('activeRoom.value'), null)
|
||||||
|
assert.equal(ui.run('invitations.value.length'), 0)
|
||||||
|
assert.equal(ui.calls.length, calls)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unmount cancels both polling timers and ignores pending lists', async () => {
|
||||||
|
const pending = deferred()
|
||||||
|
const ui = setup(fixture(url => url === '/api/collaboration' ? pending.promise : undefined))
|
||||||
|
ui.mount()
|
||||||
|
assert.equal(ui.timers.size, 2)
|
||||||
|
ui.close()
|
||||||
|
pending.resolve({ rooms: [sharedRoom], invitations: [] })
|
||||||
|
await settle()
|
||||||
|
assert.equal(ui.timers.size, 0)
|
||||||
|
assert.equal(ui.run('collaborationRooms.value.length'), 0)
|
||||||
|
assert.equal(ui.run('messages.value.length'), 0)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user