feat(communication): integrate collaboration rooms into normal chat
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<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 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)
|
||||
@@ -95,9 +95,9 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
|
||||
</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.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -1,219 +1,231 @@
|
||||
<script setup>
|
||||
const toast = useToast()
|
||||
const { $api } = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const baseRooms = ref([])
|
||||
const projectRooms = ref([])
|
||||
const directRooms = ref([])
|
||||
const unreadRooms = ref({})
|
||||
const messages = ref([])
|
||||
const members = ref([])
|
||||
const draft = ref("")
|
||||
const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
|
||||
const loading = ref(true)
|
||||
const roomLoading = ref(false)
|
||||
const sending = ref(false)
|
||||
const creating = ref(false)
|
||||
const showRoomForm = ref(false)
|
||||
const messagesViewport = ref(null)
|
||||
const roomForm = reactive({ name: "", key: "", topic: "" })
|
||||
let pollTimer = null
|
||||
let pollBusy = false
|
||||
let syncAfterId = 0
|
||||
|
||||
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 baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
|
||||
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
|
||||
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref('')
|
||||
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
|
||||
const activeRoomKey = ref(requestedRoom())
|
||||
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
|
||||
const showRoomForm = ref(false), messagesViewport = ref(null)
|
||||
const roomForm = reactive({ name: '', key: '', topic: '' })
|
||||
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`)
|
||||
const hasSession = computed(() => !!auth.user && !!auth.activeTenant)
|
||||
let pollTimer, listTimer, pollBusy = false, mounted = false, disposed = false
|
||||
let scopeVersion = 0, roomVersion = 0, collaborationRequest = 0, nativeListRequest = 0, syncAfterId = 0
|
||||
const currentScope = version => !disposed && version === scopeVersion
|
||||
const selection = () => ({ scope: scopeVersion, version: roomVersion, key: activeRoomKey.value })
|
||||
const currentSelection = request => currentScope(request.scope) && request.version === roomVersion && request.key === activeRoomKey.value
|
||||
const errorMessage = error => error?.data?.message || error?.data?.error || 'Die Anfrage konnte nicht ausgeführt werden.'
|
||||
// Colons cannot occur in normalized internal room keys, so both room types stay unambiguous.
|
||||
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 roomKeyPreview = computed(() => normalizeRoomKey(roomForm.key || roomForm.name))
|
||||
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" })),
|
||||
...directRooms.value.map((room) => ({ ...room, group: "Direktnachrichten", icon: "i-heroicons-user-circle" }))
|
||||
].map((room) => ({ ...room, unread: unreadRooms.value[room.key]?.count || 0 })))
|
||||
const groupedRooms = computed(() => ["Räume", "Projekte", "Direktnachrichten"]
|
||||
.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 roomEndpoint = computed(() => `/api/communication/chat/rooms/${encodeURIComponent(activeRoomKey.value)}`)
|
||||
...[
|
||||
...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' })),
|
||||
...directRooms.value.map(room => ({ ...room, group: 'Direktnachrichten', icon: 'i-heroicons-user-circle' }))
|
||||
].map(room => ({ ...room, source: 'internal', unread: unreadRooms.value[room.key]?.count || 0 })),
|
||||
...collaborationRooms.value.map(room => ({ ...room, key: collaborationKey(room.id), source: 'collaboration', exists: true,
|
||||
group: 'Zusammenarbeit', icon: 'i-heroicons-user-group', unread: room.unread || 0 }))
|
||||
])
|
||||
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) => {
|
||||
const byId = new Map(messages.value.map((message) => [message.id, message]))
|
||||
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; syncAfterId = 0; roomLoading.value = false; sending.value = false }
|
||||
function mergeMessages(incoming) {
|
||||
const byId = new Map(messages.value.map(message => [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))
|
||||
syncAfterId = Number(messages.value.at(-1)?.id || syncAfterId)
|
||||
messages.value = [...byId.values()].sort((a, b) => Number(a.id) - Number(b.id))
|
||||
syncAfterId = Number(messages.value.at(-1)?.id || 0)
|
||||
}
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
const scrollToBottom = async request => {
|
||||
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 {
|
||||
unreadRooms.value = (await $api("/api/communication/chat/unread")).rooms || {}
|
||||
} catch {
|
||||
unreadRooms.value = {}
|
||||
}
|
||||
const result = await $api('/api/communication/chat/unread')
|
||||
if (currentScope(scope)) unreadRooms.value = result.rooms || {}
|
||||
} catch { /* A count failure must not interrupt an open conversation. */ }
|
||||
}
|
||||
|
||||
const loadRoomLists = async () => {
|
||||
const [rooms, projects, directs] = await Promise.all([
|
||||
$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
|
||||
async function loadRoomLists(scope = scopeVersion) {
|
||||
if (!hasSession.value || !currentScope(scope)) return
|
||||
const request = ++nativeListRequest
|
||||
try {
|
||||
await $api(`${roomEndpoint.value}/read`, {
|
||||
method: "POST",
|
||||
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`)
|
||||
const [rooms, projects, directs] = await Promise.all([
|
||||
$api('/api/communication/chat/rooms'), $api('/api/communication/chat/project-rooms'), $api('/api/communication/chat/direct-rooms')
|
||||
])
|
||||
messages.value = []
|
||||
mergeMessages(messageResult.messages || [])
|
||||
members.value = memberResult.members || []
|
||||
await markRead()
|
||||
await scrollToBottom()
|
||||
} catch (error) {
|
||||
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geladen werden", color: "error" })
|
||||
} finally {
|
||||
roomLoading.value = false
|
||||
}
|
||||
if (!currentScope(scope) || request !== nativeListRequest) return
|
||||
baseRooms.value = rooms.rooms || []; projectRooms.value = projects.rooms || []; directRooms.value = directs.rooms || []
|
||||
listError.value = ''
|
||||
} catch (error) { if (currentScope(scope) && request === nativeListRequest) listError.value = errorMessage(error) }
|
||||
}
|
||||
|
||||
const provisionRoom = async (room) => {
|
||||
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" })
|
||||
await loadRoomLists()
|
||||
activeRoomKey.value = created.key
|
||||
await router.replace({ query: { ...route.query, room: created.key } })
|
||||
}
|
||||
|
||||
const selectRoom = async (room) => {
|
||||
async function loadCollaboration(scope = scopeVersion) {
|
||||
if (!hasSession.value || !currentScope(scope)) return
|
||||
const request = ++collaborationRequest
|
||||
try {
|
||||
if (!room.exists) await provisionRoom(room)
|
||||
else {
|
||||
activeRoomKey.value = room.key
|
||||
await router.replace({ query: { ...route.query, room: room.key } })
|
||||
}
|
||||
messages.value = []
|
||||
members.value = []
|
||||
await loadActiveRoom()
|
||||
const result = await $api('/api/collaboration')
|
||||
if (!currentScope(scope) || request !== collaborationRequest) return
|
||||
// Managed rooms alone never grant chat access. Only explicit memberships enter this list.
|
||||
collaborationRooms.value = result.rooms || []; invitations.value = result.invitations || []
|
||||
collaborationError.value = ''
|
||||
} 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 () => {
|
||||
if (!roomForm.name.trim()) return
|
||||
async function markRead(request) {
|
||||
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
|
||||
try {
|
||||
const created = await $api("/api/communication/chat/rooms", {
|
||||
method: "POST",
|
||||
body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
|
||||
const created = await $api('/api/communication/chat/rooms', {
|
||||
method: 'POST', body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
|
||||
})
|
||||
Object.assign(roomForm, { name: "", key: "", topic: "" })
|
||||
showRoomForm.value = false
|
||||
await loadRoomLists()
|
||||
await selectRoom(created)
|
||||
} catch (error) {
|
||||
toast.add({ title: error?.data?.error || "Chatraum konnte nicht erstellt werden", color: "error" })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
if (!currentScope(scope)) return
|
||||
Object.assign(roomForm, { name: '', key: '', topic: '' }); showRoomForm.value = false
|
||||
await loadRoomLists(scope)
|
||||
if (currentScope(scope)) await selectRoom(created)
|
||||
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||
finally { if (currentScope(scope)) creating.value = false }
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = draft.value.trim()
|
||||
if (!text || sending.value || !activeRoom.value?.exists) return
|
||||
sending.value = true
|
||||
draft.value = ""
|
||||
async function respondToInvitation(id, accept) {
|
||||
if (invitationBusy.value) return
|
||||
const scope = scopeVersion
|
||||
invitationBusy.value = id
|
||||
try {
|
||||
const message = await $api(`${roomEndpoint.value}/messages`, { method: "POST", body: { text } })
|
||||
mergeMessages([message])
|
||||
await markRead()
|
||||
await scrollToBottom()
|
||||
} catch (error) {
|
||||
draft.value = text
|
||||
toast.add({ title: error?.data?.error || "Nachricht konnte nicht gesendet werden", color: "error" })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
const result = await $api(`/api/collaboration/invitations/${encodeURIComponent(id)}/${accept ? 'accept' : 'decline'}`, { method: 'POST' })
|
||||
if (!currentScope(scope)) return
|
||||
await loadCollaboration(scope)
|
||||
if (!currentScope(scope)) return
|
||||
const room = decoratedRooms.value.find(room => room.key === collaborationKey(result.roomId))
|
||||
if (accept && room) await selectRoom(room)
|
||||
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||
finally { if (currentScope(scope)) invitationBusy.value = '' }
|
||||
}
|
||||
|
||||
const pollMessages = async () => {
|
||||
if (pollBusy || document.hidden || !activeRoom.value?.exists) return
|
||||
async function sendMessage() {
|
||||
const text = draft.value.trim(), request = selection()
|
||||
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
|
||||
try {
|
||||
const result = await $api(`${roomEndpoint.value}/sync?afterId=${syncAfterId}`)
|
||||
if (result.messages?.length) {
|
||||
mergeMessages(result.messages)
|
||||
await markRead()
|
||||
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)
|
||||
const result = await $api(nativeEndpoint(request.key, `/sync?afterId=${syncAfterId}`))
|
||||
if (!currentSelection(request)) return
|
||||
if (result.messages?.length) { mergeMessages(result.messages); await markRead(request); await scrollToBottom(request) }
|
||||
await loadUnread(request.scope)
|
||||
} catch (error) {
|
||||
toast.add({ title: error?.data?.error || "Chat konnte nicht geladen werden", color: "error" })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
if (currentSelection(request) && [401, 403, 404, 410].includes(error?.statusCode || error?.status)) {
|
||||
++roomVersion; clearMessages(); void loadRoomLists(request.scope)
|
||||
}
|
||||
} 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(() => {
|
||||
if (pollTimer) window.clearInterval(pollTimer)
|
||||
async function initialize() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
@@ -233,6 +245,22 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</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-else class="space-y-5">
|
||||
<section v-for="group in groupedRooms" :key="group.label">
|
||||
@@ -248,7 +276,7 @@ onBeforeUnmount(() => {
|
||||
<UIcon :name="room.icon" class="size-5 shrink-0" />
|
||||
<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-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>
|
||||
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
|
||||
</button>
|
||||
@@ -256,7 +284,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
@@ -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 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">
|
||||
<UIcon name="i-heroicons-chat-bubble-oval-left-ellipsis" class="mb-3 size-10" />
|
||||
<p class="font-medium">Noch keine Nachrichten</p>
|
||||
@@ -286,7 +318,7 @@ onBeforeUnmount(() => {
|
||||
</form>
|
||||
</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>
|
||||
<div class="space-y-3">
|
||||
<div v-for="member in members" :key="member.userId" class="flex items-center gap-3">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const { $api } = useNuxtApp()
|
||||
const route = useRoute()
|
||||
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 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 })
|
||||
@@ -73,6 +73,7 @@ async function savePartner(origin, active = true) {
|
||||
})
|
||||
}
|
||||
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
|
||||
onMounted(async () => { await refresh(); timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000) })
|
||||
onBeforeUnmount(() => clearInterval(timer))
|
||||
@@ -120,7 +121,10 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
</button>
|
||||
</aside>
|
||||
<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">
|
||||
<form class="space-y-3" @submit.prevent="invitePerson">
|
||||
<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