Files
BRETT/app/pages/messages/[[id]].vue
T
FliesentischKingandDevin 44968b1557 Allow leaving groups and removing members
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 12:15:30 +01:00

1275 lines
49 KiB
Vue

<script setup lang="ts">
import { useDropZone, useEventListener } from '@vueuse/core'
import { toast } from 'vue-sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ColorPicker } from '@/components/ui/color-picker'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import type { DmGroup, DmMessage, DmReactionEvent, DmUser } from '~~/shared/utils/messages'
import { formatFileSize, isImageAttachment } from '~~/shared/utils/attachments'
import { getYoutubeId } from '~~/shared/utils/youtube'
import { getTwitchTarget } from '~~/shared/utils/twitch'
definePageMeta({ middleware: 'auth' })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { user } = useAuth()
const dms = useDirectMessages()
const timeAgo = useTimeAgo()
const { byName } = useStickers()
const { limits } = useLimits()
const maxDmAttachments = computed(() => limits.value.dmMaxAttachments)
const maxAttachmentSize = computed(() => limits.value.uploadMaxSizeMb * 1024 * 1024)
const partnerId = computed(() => {
const n = Number(route.params.id)
return Number.isInteger(n) && n > 0 ? n : null
})
// Gruppen-URLs tragen ein g-Präfix, um sie von Nutzer-Ids zu unterscheiden:
// /messages/4 = 1:1 mit Nutzer 4, /messages/g7 = Gruppen-Chat 7
const groupId = computed(() => {
const p = String(route.params.id ?? '')
if (!p.startsWith('g')) return null
const n = Number(p.slice(1))
return Number.isInteger(n) && n > 0 ? n : null
})
watchEffect(() => {
const legacy = Number(route.query.to)
if (Number.isInteger(legacy) && legacy > 0) router.replace(`/messages/${legacy}`)
})
function openConversation(id: number) {
router.replace(`/messages/${id}`)
}
function openGroup(id: number) {
router.replace(`/messages/g${id}`)
}
function closeConversation() {
router.replace('/messages')
}
// --- Chat-Ansicht
const partner = ref<DmUser | null>(null)
const group = ref<DmGroup | null>(null)
const chatMessages = ref<DmMessage[]>([])
const messageIds = new Set<number>()
const hasMore = ref(false)
const loadingChat = ref(false)
const loadingOlder = ref(false)
const listEl = ref<HTMLElement>()
const draft = ref('')
const sending = ref(false)
const sendProgress = ref(0)
const mentionAreaRef = ref<{ insert: (text: string) => Promise<void> } | null>(null)
// Anhänge - gleiches Pending-Pattern wie im Kommentar-Formular:
// Dateien + fertige Sprachnachrichten landen als Chips bis zum Senden
const pendingFiles = ref<File[]>([])
const voicePending = ref(new Set<File>())
// Serverseitige Rauschunterdrückung - nsPending markiert die Dateien dafür
const { enabled: nsEnabled, toggle: nsToggle } = useNoiseSuppression()
// Sticker-Popover rendert per Portal ausserhalb des Composers - der
// Hover-Reveal des NS-Buttons darf dann nicht einklappen
const stickerPickerOpen = ref(false)
const nsPending = ref(new Set<File>())
const pendingPreviews = new Map<File, string>()
const fileInputRef = ref<HTMLInputElement>()
const composerFormRef = ref<HTMLElement>()
// Drag&Drop auf den Composer - wie im Kommentar-Formular; die
// seitenweite Dropzone wird per stopPropagation nicht ausgelöst
const { isOverDropZone: isOverComposerDrop } = useDropZone(composerFormRef, {
checkValidity: items => Array.from(items).some(i => i.kind === 'file'),
onDrop(files) {
if (files?.length) addPendingFiles(files)
},
})
useEventListener(composerFormRef, ['dragenter', 'dragover', 'dragleave', 'drop'], e => e.stopPropagation())
function pendingPreview(file: File): string | null {
if (!isImageAttachment({ mimeType: file.type })) return null
let url = pendingPreviews.get(file)
if (!url) {
url = URL.createObjectURL(file)
pendingPreviews.set(file, url)
}
return url
}
function addPendingFiles(files: File[]) {
for (const file of files) {
if (pendingFiles.value.length >= maxDmAttachments.value) {
toast.error(t('comments.maxAttachments', { n: maxDmAttachments.value }))
break
}
if (file.size > maxAttachmentSize.value) {
toast.error(t('comments.fileTooLarge', { name: file.name, n: limits.value.uploadMaxSizeMb }))
continue
}
pendingFiles.value.push(file)
}
}
function removePendingFile(index: number) {
const file = pendingFiles.value[index]
const url = file && pendingPreviews.get(file)
if (url) {
URL.revokeObjectURL(url)
pendingPreviews.delete(file!)
}
if (file) {
voicePending.value.delete(file)
nsPending.value.delete(file)
}
pendingFiles.value.splice(index, 1)
}
function clearPendingFiles() {
for (const url of pendingPreviews.values()) URL.revokeObjectURL(url)
pendingPreviews.clear()
pendingFiles.value = []
voicePending.value.clear()
nsPending.value.clear()
if (fileInputRef.value) fileInputRef.value.value = ''
}
function scrollToBottom() {
const el = listEl.value
if (el) el.scrollTop = el.scrollHeight
}
function nearBottom() {
const el = listEl.value
return !el || el.scrollHeight - el.scrollTop - el.clientHeight < 120
}
async function loadConversation(id: number, before?: number) {
try {
const res = await $fetch<{ partner: DmUser, messages: DmMessage[], hasMore: boolean }>(
`/api/messages/${id}`,
{ query: before ? { before } : undefined },
)
if (partnerId.value !== id) return
partner.value = res.partner
if (before) {
const fresh = res.messages.filter(m => !messageIds.has(m.id))
for (const m of fresh) messageIds.add(m.id)
chatMessages.value = [...fresh, ...chatMessages.value]
}
else {
messageIds.clear()
for (const m of res.messages) messageIds.add(m.id)
chatMessages.value = res.messages
await nextTick()
scrollToBottom()
}
hasMore.value = res.hasMore
}
catch {
toast.error(t('dm.loadFailed'))
}
}
async function loadGroup(id: number, before?: number) {
try {
const res = await $fetch<{ group: DmGroup, messages: DmMessage[], hasMore: boolean }>(
`/api/messages/groups/${id}`,
{ query: before ? { before } : undefined },
)
if (groupId.value !== id) return
group.value = res.group
if (before) {
const fresh = res.messages.filter(m => !messageIds.has(m.id))
for (const m of fresh) messageIds.add(m.id)
chatMessages.value = [...fresh, ...chatMessages.value]
}
else {
messageIds.clear()
for (const m of res.messages) messageIds.add(m.id)
chatMessages.value = res.messages
await nextTick()
scrollToBottom()
}
hasMore.value = res.hasMore
}
catch {
toast.error(t('dm.loadFailed'))
}
}
async function loadOlder() {
const first = chatMessages.value[0]
const convoId = partnerId.value ?? groupId.value
if (!hasMore.value || !first || !convoId) return
const el = listEl.value
const oldHeight = el?.scrollHeight ?? 0
const oldTop = el?.scrollTop ?? 0
loadingOlder.value = true
if (groupId.value) await loadGroup(groupId.value, first.id)
else await loadConversation(convoId, first.id)
loadingOlder.value = false
await nextTick()
if (el) el.scrollTop = el.scrollHeight - oldHeight + oldTop
}
async function markRead() {
const id = partnerId.value
if (!id) return
dms.markConversationRead(id)
await $fetch(`/api/messages/${id}/read`, { method: 'POST' }).catch(() => {})
}
async function markGroupRead() {
const id = groupId.value
if (!id) return
dms.markGroupRead(id)
// Offene Einladung: lokal als gesehen markieren, aber keinen
// Read-State setzen (dazu waere Mitgliedschaft noetig)
if (pendingInvite.value) return
await $fetch(`/api/messages/groups/${id}/read`, { method: 'POST' }).catch(() => {})
}
// Sprachnachrichten: MediaRecorder-Aufnahme, Ergebnis wird als 'voice'-Anhang
// in den Pending-Dateien gesendet - gleiches Verhalten wie in den Kommentaren
const canRecord = ref(false)
const recording = ref(false)
const recordSeconds = ref(0)
let recorder: MediaRecorder | null = null
let recordStream: MediaStream | null = null
let recordTimer: ReturnType<typeof setInterval> | null = null
let recordChunks: Blob[] = []
let recordDiscarded = false
// Toggle-Stand bei Aufnahmestart - entscheidet über serverseitige Entrauschung
let recordNsEnabled = false
const maxRecordSeconds = computed(() => limits.value.voiceMaxSeconds)
onMounted(() => {
canRecord.value = !!navigator.mediaDevices?.getUserMedia
&& typeof MediaRecorder !== 'undefined'
})
function recorderMimeType() {
for (const t of ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4'])
if (MediaRecorder.isTypeSupported(t)) return t
return ''
}
async function startRecording() {
if (!canRecord.value || recording.value || pendingFiles.value.length >= maxDmAttachments.value) return
try {
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true })
}
catch {
toast.error(t('comments.noMic'))
return
}
const mimeType = recorderMimeType()
recordChunks = []
recordDiscarded = false
recordNsEnabled = nsEnabled.value
recorder = new MediaRecorder(recordStream, mimeType ? { mimeType } : undefined)
recorder.ondataavailable = e => { if (e.data.size) recordChunks.push(e.data) }
recorder.onstop = () => {
recordStream?.getTracks().forEach(tr => tr.stop())
recordStream = null
if (!recordDiscarded && recordChunks.length && pendingFiles.value.length < maxDmAttachments.value) {
const type = recorder?.mimeType || 'audio/webm'
const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : 'webm'
const file = new File(recordChunks, `${t('comments.voiceMessage')}.${ext}`, { type })
pendingFiles.value.push(file)
voicePending.value.add(file)
if (recordNsEnabled) nsPending.value.add(file)
}
recordChunks = []
recorder = null
}
recordSeconds.value = 0
recordTimer = setInterval(() => {
recordSeconds.value++
if (recordSeconds.value >= maxRecordSeconds.value) stopRecording()
}, 1000)
recorder.start(250)
recording.value = true
}
function stopRecording() {
if (recordTimer) { clearInterval(recordTimer); recordTimer = null }
recording.value = false
recorder?.stop()
}
function cancelRecording() {
recordDiscarded = true
stopRecording()
}
watch([partnerId, groupId], async ([pid, gid]) => {
cancelRecording()
clearPendingFiles()
dms.openPartnerId.value = pid
dms.openGroupId.value = gid
chatMessages.value = []
messageIds.clear()
hasMore.value = false
partner.value = null
group.value = null
if (gid) {
group.value = dms.conversations.value.find(c => c.kind === 'group' && c.group?.id === gid)?.group ?? null
loadingChat.value = true
await loadGroup(gid)
loadingChat.value = false
markGroupRead()
return
}
partner.value = dms.conversations.value.find(c => c.kind === 'dm' && c.user?.id === pid)?.user ?? null
if (!pid) return
loadingChat.value = true
await loadConversation(pid)
loadingChat.value = false
markRead()
}, { immediate: true })
function onLiveMessage(msg: DmMessage, other: DmUser | null, grp: DmGroup | null) {
if (grp) {
if (grp.id !== groupId.value) return
group.value = grp
if (messageIds.has(msg.id)) return
messageIds.add(msg.id)
chatMessages.value.push(msg)
const stick = nearBottom()
nextTick(() => { if (stick) scrollToBottom() })
if (msg.senderId !== user.value?.id) markGroupRead()
return
}
const pid = partnerId.value
if (!pid || (msg.senderId !== pid && msg.recipientId !== pid)) return
if (messageIds.has(msg.id)) return
if (!partner.value && other) partner.value = other
messageIds.add(msg.id)
chatMessages.value.push(msg)
const stick = nearBottom()
nextTick(() => { if (stick) scrollToBottom() })
if (msg.recipientId === user.value?.id) markRead()
}
// Mitglieder-Updates der geöffneten Gruppe (Einladung eines neuen Nutzers)
function onGroupUpdate(g: DmGroup) {
if (g.id !== groupId.value) return
group.value = g
// Entfernt worden / anderweitig ausgetreten → Ansicht schließen
if (!g.members.some(m => m.id === user.value?.id)) closeConversation()
}
// Mitgliederliste: Kick nur für den Ersteller, Verlassen für alle
const membersOpen = ref(false)
const isGroupCreator = computed(() => !!user.value && group.value?.createdBy === user.value.id)
const memberBusyId = ref<number | null>(null)
const leavingGroup = ref(false)
async function kickMember(memberId: number) {
const gid = groupId.value
if (!gid || memberBusyId.value !== null) return
memberBusyId.value = memberId
try {
const updated = await $fetch<DmGroup>(`/api/messages/groups/${gid}/members/${memberId}`, { method: 'DELETE' })
group.value = updated
dms.handleGroupUpdate(updated)
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.kickFailed'))
}
finally {
memberBusyId.value = null
}
}
async function leaveGroup() {
const gid = groupId.value
if (!gid || leavingGroup.value) return
leavingGroup.value = true
try {
await $fetch(`/api/messages/groups/${gid}/leave`, { method: 'POST' })
membersOpen.value = false
dms.removeGroupConversation(gid)
closeConversation()
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.leaveFailed'))
}
finally {
leavingGroup.value = false
}
}
// Reaction-State pro Nachricht - gleiche Semantik wie useCommentReactions
function applyDmReaction(m: DmMessage, e: { emoji: string, actorId: number, actorName: string, active: boolean, count: number, sticker?: boolean }) {
m.reactions ??= []
let entry = m.reactions.find(r => r.emoji === e.emoji)
if (e.active) {
if (!entry) {
entry = { emoji: e.emoji, count: 0, me: false, names: [], sticker: e.sticker }
m.reactions.push(entry)
}
if (!entry.names.includes(e.actorName)) entry.names.push(e.actorName)
}
else if (entry) {
entry.names = entry.names.filter(n => n !== e.actorName)
}
if (!entry) return
entry.count = e.count
if (e.actorId === user.value?.id) entry.me = e.active
if (entry.count <= 0) m.reactions.splice(m.reactions.indexOf(entry), 1)
}
function onLiveReaction(e: DmReactionEvent) {
const msg = chatMessages.value.find(m => m.id === e.messageId)
if (msg) applyDmReaction(msg, e)
if (e.actorId !== user.value?.id) {
if (e.group) markGroupRead()
else markRead()
}
}
async function toggleReaction(m: DmMessage, emoji: string, sticker = false) {
const me = user.value
if (!me) return
const before = m.reactions?.find(r => r.emoji === emoji)
// Optimistisch umschalten - Server-Antwort korrigiert Zähler/Status
applyDmReaction(m, {
emoji,
actorId: me.id,
actorName: me.name,
active: !before?.me,
count: (before?.count ?? 0) + (before?.me ? -1 : 1),
sticker,
})
try {
const res = await $fetch<{ active: boolean, count: number }>(`/api/messages/reactions/${m.id}`, {
method: 'POST',
body: { emoji, sticker },
})
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count, sticker })
}
catch {
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0, sticker })
toast.error(t('comments.reactionFailed'))
}
}
function onLiveRead(pid: number, at: string) {
if (pid !== partnerId.value) return
for (const m of chatMessages.value) {
if (m.senderId === user.value?.id && !m.readAt) m.readAt = at
}
}
const loadingList = ref(true)
onMounted(() => {
dms.liveMessageHandler.value = onLiveMessage
dms.liveReadHandler.value = onLiveRead
dms.liveReactionHandler.value = onLiveReaction
dms.liveGroupHandler.value = onGroupUpdate
dms.fetchConversations()
.catch(() => {})
.finally(() => { loadingList.value = false })
})
onUnmounted(() => {
cancelRecording()
clearPendingFiles()
dms.openPartnerId.value = null
dms.openGroupId.value = null
dms.liveMessageHandler.value = null
dms.liveReadHandler.value = null
dms.liveReactionHandler.value = null
dms.liveGroupHandler.value = null
})
async function send() {
const content = draft.value.trim()
const gid = groupId.value
const id = partnerId.value
const hasFiles = pendingFiles.value.length > 0
if ((!content && !hasFiles) || (!id && !gid) || sending.value) return
const url = gid ? `/api/messages/groups/${gid}` : `/api/messages/${id}`
sending.value = true
sendProgress.value = 0
try {
let msg: DmMessage
if (hasFiles) {
const fd = new FormData()
fd.append('content', content)
for (const file of pendingFiles.value) fd.append(voicePending.value.has(file) ? 'voice' : 'files', file)
const denoiseIdx = pendingFiles.value.map((f, i) => nsPending.value.has(f) ? i : -1).filter(i => i >= 0)
if (denoiseIdx.length) fd.append('denoise', denoiseIdx.join(','))
msg = await uploadWithProgress<DmMessage>(url, fd, p => sendProgress.value = p)
}
else {
msg = await $fetch<DmMessage>(url, { method: 'POST', body: { content } })
}
draft.value = ''
clearPendingFiles()
if (!messageIds.has(msg.id)) {
messageIds.add(msg.id)
chatMessages.value.push(msg)
await nextTick()
scrollToBottom()
}
if (gid && group.value) dms.addOwnGroupMessage(group.value, msg)
else if (partner.value) dms.addOwnMessage(partner.value, msg)
}
catch {
toast.error(t('dm.sendFailed'))
}
finally {
sending.value = false
}
}
function onEnter(e: KeyboardEvent) {
if (e.defaultPrevented) return // Enter wurde vom Mention-Popup konsumiert
e.preventDefault()
send()
}
function insertSticker(name: string) {
mentionAreaRef.value?.insert(`:${name}:`)
}
// Nachrichten mit Video-Embed (YouTube/Twitch) bekommen eine breitere
// Bubble, damit der Player nicht auf Linkbreite zusammenschrumpft
function hasVideoEmbed(content: string) {
return content.split(/\s+/).some(tok => getYoutubeId(tok) || getTwitchTarget(tok))
}
const lastOwnId = computed(() => {
for (let i = chatMessages.value.length - 1; i >= 0; i--) {
if (chatMessages.value[i]!.senderId === user.value?.id) return chatMessages.value[i]!.id
}
return null
})
// --- Gruppen-Header + Einladen ---
const { data: mentionUsers } = useMentionUsers()
// Sender einer Nachricht auflösen - Payload trägt ihn mit, Fallback ist
// die Mitgliederliste (z. B. für Verläufe älterer Payloads)
function senderOf(m: DmMessage): DmUser | null {
return m.sender ?? group.value?.members.find(u => u.id === m.senderId) ?? null
}
const groupTitle = computed(() => {
const g = group.value
if (!g) return ''
if (g.name) return g.name
const others = g.members.filter(m => m.id !== user.value?.id)
return (others.length ? others : g.members).map(m => m.name).join(', ')
})
// Offene Einladung in die geöffnete Gruppe - zeigt die Invite-Card
// statt Verlauf/Composer an
const pendingInvite = computed(() => dms.isPendingInvite(group.value))
const inviterName = computed(() => {
const me_ = group.value?.members.find(m => m.id === user.value?.id)
return me_?.invitedBy ? (group.value?.members.find(m => m.id === me_.invitedBy)?.name ?? null) : null
})
const inviteResponding = ref(false)
async function acceptInvite() {
const gid = groupId.value
if (!gid || inviteResponding.value) return
inviteResponding.value = true
try {
const updated = await $fetch<DmGroup>(`/api/messages/groups/${gid}/accept`, { method: 'POST' })
group.value = updated
dms.handleGroupUpdate(updated)
await loadGroup(gid)
markGroupRead()
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.inviteAcceptFailed'))
}
finally {
inviteResponding.value = false
}
}
async function declineInvite() {
const gid = groupId.value
if (!gid || inviteResponding.value) return
inviteResponding.value = true
try {
await dms.declineGroupInvite(gid)
closeConversation()
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.inviteDeclineFailed'))
}
finally {
inviteResponding.value = false
}
}
// Tab-Titel: Gruppenname, Chatpartner oder generisch „Nachrichten"
useHead({
title: computed(() => {
if (group.value) return groupTitle.value
if (partner.value) return partner.value.name
return t('dm.title')
}),
})
const inviteOpen = ref(false)
const inviteSearch = ref('')
const inviteBusyId = ref<number | null>(null)
const inviteCandidates = computed(() => {
const q = inviteSearch.value.trim().toLowerCase()
const memberIds = new Set(group.value?.members.map(m => m.id) ?? [])
return mentionUsers.value
.filter(u => !memberIds.has(u.id) && (!q || u.name.toLowerCase().includes(q)))
.slice(0, 10)
})
async function inviteMember(u: { id: number }) {
const gid = groupId.value
if (!gid || inviteBusyId.value !== null) return
inviteBusyId.value = u.id
try {
const updated = await $fetch<DmGroup>(`/api/messages/groups/${gid}/members`, {
method: 'POST',
body: { userId: u.id },
})
group.value = updated
dms.handleGroupUpdate(updated)
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.inviteFailed'))
}
finally {
inviteBusyId.value = null
}
}
// Gruppe bearbeiten (Bild + Farbe) - jedes Mitglied darf, Update kommt
// per WS an alle
const editDialogOpen = ref(false)
const avatarFile = ref<File | null>(null)
const editColor = ref<string | null>(null)
const editSaving = ref(false)
function openGroupEdit() {
editColor.value = group.value?.color ?? null
avatarFile.value = null
editDialogOpen.value = true
}
async function saveGroupEdit() {
const gid = groupId.value
const colorChanged = editColor.value !== (group.value?.color ?? null)
if (!gid || (!avatarFile.value && !colorChanged) || editSaving.value) return
editSaving.value = true
try {
const fd = new FormData()
if (avatarFile.value) fd.append('avatar', avatarFile.value)
if (colorChanged) fd.append('color', editColor.value ?? '')
const updated = await $fetch<DmGroup>(`/api/messages/groups/${gid}/update`, {
method: 'POST',
body: fd,
})
group.value = updated
dms.handleGroupUpdate(updated)
editDialogOpen.value = false
avatarFile.value = null
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.updateFailed'))
}
finally {
editSaving.value = false
}
}
</script>
<template>
<div class="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<SidebarProvider
class="min-h-0! flex-1"
:style="{ '--sidebar-width': '22rem' }"
>
<DmSidebar
:conversations="dms.conversations.value"
:partner-id="partnerId"
:group-id="groupId"
:loading="loadingList"
@select="openConversation"
@select-group="openGroup"
/>
<SidebarInset class="min-h-0">
<header class="flex shrink-0 items-center gap-2 border-b bg-background p-4">
<Button
v-if="partnerId || groupId"
variant="ghost"
size="icon"
class="size-8 md:hidden"
:aria-label="$t('dm.back')"
@click="closeConversation"
>
<Icon name="ph:arrow-left" class="size-5" />
</Button>
<template v-if="group">
<Tooltip v-if="!pendingInvite">
<TooltipTrigger as-child>
<button
type="button"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm bg-muted text-muted-foreground transition-colors hover:ring-2 hover:ring-primary/40"
:aria-label="$t('dm.editGroup')"
@click="openGroupEdit"
>
<img
v-if="group.avatarUrl"
:src="group.avatarUrl"
:alt="groupTitle"
class="size-full object-cover"
>
<Icon v-else name="ph:users-three-fill" class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.editGroup') }}</TooltipContent>
</Tooltip>
<span
v-else
class="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted text-muted-foreground"
>
<img
v-if="group.avatarUrl"
:src="group.avatarUrl"
:alt="groupTitle"
class="size-full object-cover"
>
<Icon v-else name="ph:users-three-fill" class="size-4" />
</span>
<div class="flex min-w-0 flex-col">
<span
class="truncate font-semibold tracking-tight"
:style="group.color ? { color: group.color } : undefined"
>{{ groupTitle }}</span>
<button
type="button"
class="w-fit cursor-pointer truncate text-xs text-muted-foreground hover:text-foreground hover:underline"
@click="membersOpen = true"
>
{{ $t('dm.memberCount', { n: group.members.filter(m => !m.pending).length }) }}
</button>
</div>
<div v-if="!pendingInvite" class="ml-auto flex items-center gap-1">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground"
:aria-label="$t('dm.invite')"
@click="inviteOpen = true"
>
<Icon name="ph:user-plus-fill" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.invite') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-8 cursor-pointer text-muted-foreground"
:aria-label="$t('dm.openFloating')"
@click="dms.openFloatingGroup(group); navigateTo('/')"
>
<Icon name="ph:picture-in-picture-fill" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.openFloating') }}</TooltipContent>
</Tooltip>
</div>
</template>
<template v-else-if="partner">
<UserHoverCard
:user-id="partner.id"
:user-name="partner.name"
:user-color="partner.color"
:user-avatar-url="partner.avatarUrl"
>
<NuxtLink :to="`/user/${partner.id}`" class="flex items-center gap-2">
<Avatar class="size-8">
<AvatarImage v-if="partner.avatarUrl" :src="partner.avatarUrl" :alt="partner.name" />
<AvatarFallback
class="text-xs"
:style="partner.color ? { backgroundColor: partner.color, color: '#fff' } : undefined"
>
{{ partner.name.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span
class="font-semibold tracking-tight hover:underline"
:style="partner.color ? { color: partner.color } : undefined"
>{{ partner.name }}</span>
</NuxtLink>
</UserHoverCard>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="ml-auto size-8 cursor-pointer text-muted-foreground"
:aria-label="$t('dm.openFloating')"
@click="dms.openFloatingChat(partner); navigateTo('/')"
>
<Icon name="ph:picture-in-picture-fill" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.openFloating') }}</TooltipContent>
</Tooltip>
</template>
<h1 v-else class="text-base font-semibold tracking-tight">{{ $t('dm.title') }}</h1>
</header>
<template v-if="partnerId || groupId">
<div v-if="group && pendingInvite" class="flex flex-1 flex-col items-center justify-center gap-4 px-4">
<div class="flex w-full max-w-sm flex-col items-center gap-4 rounded-lg border bg-card p-6 text-center shadow-sm">
<span class="flex size-12 items-center justify-center overflow-hidden rounded-md bg-muted text-muted-foreground">
<img
v-if="group.avatarUrl"
:src="group.avatarUrl"
:alt="groupTitle"
class="size-full object-cover"
>
<Icon v-else name="ph:users-three-fill" class="size-6" />
</span>
<div class="flex flex-col gap-1">
<p
class="font-semibold tracking-tight"
:style="group.color ? { color: group.color } : undefined"
>{{ groupTitle }}</p>
<p class="text-sm text-muted-foreground">
{{ inviterName ? $t('dm.invitedBy', { name: inviterName }) : $t('dm.invitedToGroup') }}
</p>
</div>
<div class="flex items-center gap-2">
<Button size="sm" :disabled="inviteResponding" @click="acceptInvite">
<Spinner v-if="inviteResponding" />
{{ $t('dm.acceptInvite') }}
</Button>
<Button size="sm" variant="outline" :disabled="inviteResponding" @click="declineInvite">
{{ $t('dm.declineInvite') }}
</Button>
</div>
</div>
</div>
<template v-else>
<div ref="listEl" class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 py-3">
<div v-if="hasMore" class="flex justify-center">
<Button variant="ghost" size="sm" :disabled="loadingOlder" @click="loadOlder">
<Spinner v-if="loadingOlder" />
{{ $t('dm.loadOlder') }}
</Button>
</div>
<div v-if="loadingChat" class="flex flex-col gap-3 py-1">
<div
v-for="(w, i) in ['55%', '40%', '65%', '35%', '50%']"
:key="i"
class="flex flex-col"
:class="i % 2 ? 'items-end' : 'items-start'"
>
<Skeleton class="h-9 rounded-md" :style="{ width: w }" />
<Skeleton class="mt-0.5 h-2.5 w-14" />
</div>
</div>
<p v-else-if="!chatMessages.length" class="py-8 text-center text-sm text-muted-foreground">
{{ $t('dm.empty') }}
</p>
<div
v-for="m in chatMessages"
:key="m.id"
:class="cn('flex flex-col', m.senderId === user?.id ? 'items-end' : 'items-start')"
>
<div
v-if="groupId && m.senderId !== user?.id"
class="flex items-center gap-1.5 px-1 pb-0.5"
>
<Avatar class="size-4">
<AvatarImage v-if="senderOf(m)?.avatarUrl" :src="senderOf(m)!.avatarUrl!" :alt="senderOf(m)?.name" />
<AvatarFallback
class="text-[8px]"
:style="senderOf(m)?.color ? { backgroundColor: senderOf(m)!.color!, color: '#fff' } : undefined"
>
{{ senderOf(m)?.name.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span
class="text-xs font-medium"
:style="senderOf(m)?.color ? { color: senderOf(m)!.color! } : undefined"
>{{ senderOf(m)?.name }}</span>
</div>
<div
class="group/message flex w-full items-center gap-1"
:class="m.senderId === user?.id && 'flex-row-reverse'"
>
<div
:class="cn(
'max-w-[75%] rounded-md px-3 py-1.5',
hasVideoEmbed(m.content) && 'w-96',
m.senderId === user?.id
? 'bg-primary text-primary-foreground'
: 'bg-muted',
)"
>
<div v-if="m.attachments?.length" class="w-96 max-w-full">
<CommentAttachments :attachments="m.attachments" />
</div>
<CommentContent v-if="m.content" :content="m.content" />
</div>
<DmReactionPicker @select="(e, s) => toggleReaction(m, e, s)" />
</div>
<div
v-if="m.reactions?.length"
class="mt-1 flex flex-wrap gap-1"
:class="m.senderId === user?.id && 'justify-end'"
>
<Tooltip v-for="r in m.reactions" :key="r.emoji">
<TooltipTrigger as-child>
<button
type="button"
:class="cn(
'flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 transition-colors',
r.me
? 'border-foreground/40 bg-secondary hover:border-foreground/60'
: 'border-border bg-muted/60 hover:border-foreground/30',
)"
@click="toggleReaction(m, r.emoji, r.sticker)"
>
<StickerMedia
v-if="r.sticker && byName.get(r.emoji)"
:sticker="byName.get(r.emoji)!"
variant="reaction"
/>
<span v-else-if="r.sticker" class="text-[10px] leading-none text-muted-foreground">:{{ r.emoji }}:</span>
<span v-else class="text-base leading-none">{{ r.emoji }}</span>
<span class="text-xs font-medium tabular-nums" :class="r.me ? 'text-foreground' : 'text-muted-foreground'">{{ r.count }}</span>
</button>
</TooltipTrigger>
<TooltipContent class="flex items-center gap-2.5 px-3 py-2">
<StickerMedia v-if="r.sticker && byName.get(r.emoji)" :sticker="byName.get(r.emoji)!" variant="compact" />
<span v-else-if="r.sticker" class="text-xl leading-none">:{{ r.emoji }}:</span>
<span v-else class="text-4xl leading-none">{{ r.emoji }}</span>
<span class="flex flex-col leading-snug">
<span v-for="n in r.names" :key="n">{{ n }}</span>
</span>
</TooltipContent>
</Tooltip>
</div>
<div class="mt-0.5 flex items-center gap-1 px-1 text-xs text-muted-foreground">
<span>{{ timeAgo(m.createdAt) }}</span>
<template v-if="!groupId && m.id === lastOwnId">
·
<Icon v-if="m.readAt" name="ph:checks-fill" class="size-3.5 text-primary" />
<Icon v-else name="ph:check-fill" class="size-3.5" />
<span>{{ m.readAt ? $t('dm.read') : $t('dm.delivered') }}</span>
</template>
</div>
</div>
</div>
<form ref="composerFormRef" class="border-t p-3" @submit.prevent="send" @keydown.enter.exact="onEnter" @keydown.ctrl.enter.exact="onEnter">
<div :class="cn('group/composer rounded-md transition-shadow', isOverComposerDrop && 'shadow-[0_0_0_2px_var(--color-primary)]')">
<MentionTextarea
ref="mentionAreaRef"
v-model="draft"
:placeholder="$t('dm.placeholder')"
@paste-files="addPendingFiles"
>
<div class="absolute right-2 bottom-2 flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-all duration-100 hover:bg-accent hover:text-accent-foreground active:scale-90 active:bg-accent/80 disabled:pointer-events-none disabled:opacity-50"
:disabled="sending || pendingFiles.length >= maxDmAttachments"
:aria-label="$t('comments.attachFiles')"
@click="fileInputRef?.click()"
>
<Icon name="ph:folder-plus-fill" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('comments.attachFiles') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="canRecord">
<TooltipTrigger as-child>
<button
type="button"
:class="cn(
'flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-all duration-100 hover:bg-accent hover:text-accent-foreground active:scale-90 disabled:pointer-events-none disabled:opacity-50',
recording && 'bg-destructive/15 text-destructive ring-1 ring-destructive/50 hover:bg-destructive/25 hover:text-destructive',
)"
:disabled="sending || (!recording && pendingFiles.length >= maxDmAttachments)"
:aria-label="recording ? $t('comments.recordStop') : $t('comments.recordStart')"
@click="recording ? stopRecording() : startRecording()"
>
<Icon :name="recording ? 'ph:stop-fill' : 'ph:microphone-fill'" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ recording ? $t('comments.recordStop') : $t('comments.recordStart') }}</TooltipContent>
</Tooltip>
<!-- Rauschunterdrückung taucht erst bei Hover/Fokus im
Eingabefeld (oder während einer Aufnahme) auf; der
Aktiv-Zustand ist nur über die Füllung sichtbar -->
<Tooltip v-if="canRecord">
<TooltipTrigger as-child>
<button
type="button"
:class="cn(
'flex h-8 cursor-pointer items-center justify-center overflow-hidden rounded-md text-muted-foreground transition-all duration-150 hover:bg-accent hover:text-accent-foreground active:scale-90',
recording || stickerPickerOpen ? 'w-8' : 'pointer-events-none w-0 opacity-0 group-hover/composer:pointer-events-auto group-hover/composer:w-8 group-hover/composer:opacity-100 group-focus-within/composer:pointer-events-auto group-focus-within/composer:w-8 group-focus-within/composer:opacity-100',
nsEnabled && 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 hover:text-primary-foreground',
)"
:aria-label="$t('comments.noiseSuppression')"
:aria-pressed="nsEnabled"
@click="nsToggle"
>
<Icon name="ph:waveform" class="size-5 shrink-0" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('comments.noiseSuppression') }}</TooltipContent>
</Tooltip>
<StickerPicker v-model:open="stickerPickerOpen" @select="insertSticker" />
<Button
type="submit"
size="icon"
class="size-8"
:disabled="sending || (!draft.trim() && !pendingFiles.length)"
:aria-label="$t('dm.send')"
>
<Spinner v-if="sending" />
<Icon v-else name="ph:paper-plane-tilt-fill" class="size-4" />
</Button>
</div>
</MentionTextarea>
</div>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="addPendingFiles(Array.from(($event.target as HTMLInputElement).files ?? []))"
>
<div v-if="pendingFiles.length" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="(file, i) in pendingFiles"
:key="i"
class="flex max-w-48 items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs"
>
<img
v-if="pendingPreview(file)"
:src="pendingPreview(file)!"
:alt="file.name"
class="size-6 shrink-0 rounded-sm object-cover"
>
<Icon
v-else
:name="voicePending.has(file) ? 'ph:microphone-fill' : 'ph:file-fill'"
class="size-3.5 shrink-0 text-muted-foreground"
/>
<span class="truncate" :title="file.name">{{ file.name }}</span>
<span class="shrink-0 text-muted-foreground">{{ formatFileSize(file.size) }}</span>
<button
type="button"
class="shrink-0 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-destructive"
:aria-label="$t('comments.removeAttachment')"
@click="removePendingFile(i)"
>
<Icon name="ph:x-bold" class="size-3.5" />
</button>
</span>
</div>
<div v-if="sending && sendProgress > 0" class="mt-2 flex items-center gap-2">
<Progress :model-value="sendProgress" class="flex-1" />
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ sendProgress }}&nbsp;%</span>
</div>
</form>
</template>
</template>
<div v-else class="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
<Icon name="ph:chats-circle-fill" class="size-10" />
<p class="text-sm">{{ $t('dm.selectConversation') }}</p>
</div>
</SidebarInset>
</SidebarProvider>
<!-- Mitgliederliste -->
<Dialog v-model:open="membersOpen">
<DialogContent v-if="group" class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{{ $t('dm.members') }}</DialogTitle>
</DialogHeader>
<ul class="flex max-h-72 flex-col gap-0.5 overflow-y-auto">
<li
v-for="m in group.members"
:key="m.id"
class="flex items-center gap-2 rounded-md px-2 py-1.5"
>
<Avatar class="size-7 shrink-0">
<AvatarImage v-if="m.avatarUrl" :src="m.avatarUrl" :alt="m.name" />
<AvatarFallback
class="text-[10px]"
:style="m.color ? { backgroundColor: m.color, color: '#fff' } : undefined"
>{{ m.name.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<span
class="min-w-0 flex-1 truncate text-sm font-medium"
:style="m.color ? { color: m.color } : undefined"
>{{ m.name }}</span>
<span
v-if="m.id === group.createdBy"
class="shrink-0 rounded-sm bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"
>{{ $t('dm.creator') }}</span>
<span
v-else-if="m.pending"
class="shrink-0 rounded-sm border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>{{ $t('dm.pending') }}</span>
<button
v-if="isGroupCreator && m.id !== user?.id"
type="button"
class="shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground hover:text-destructive disabled:opacity-50"
:disabled="memberBusyId !== null"
:aria-label="$t('dm.kickMember')"
@click="kickMember(m.id)"
>
<Spinner v-if="memberBusyId === m.id" />
<Icon v-else name="ph:x-bold" class="size-3.5" />
</button>
</li>
</ul>
<div v-if="!pendingInvite" class="flex justify-end border-t pt-3">
<Button
variant="destructive"
size="sm"
:disabled="leavingGroup"
@click="leaveGroup"
>
<Spinner v-if="leavingGroup" />
{{ $t('dm.leaveGroup') }}
</Button>
</div>
</DialogContent>
</Dialog>
<!-- Mitglied einladen -->
<Dialog v-model:open="inviteOpen">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{{ $t('dm.invite') }}</DialogTitle>
</DialogHeader>
<Input
v-model="inviteSearch"
:placeholder="$t('dm.inviteSearch')"
/>
<div class="max-h-56 overflow-y-auto rounded-md border">
<button
v-for="u in inviteCandidates"
:key="u.id"
type="button"
class="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-accent disabled:opacity-50"
:disabled="inviteBusyId !== null"
@click="inviteMember(u)"
>
<Icon name="ph:user-fill" class="size-4 shrink-0" :style="u.color ? { color: u.color } : undefined" />
<span class="min-w-0 flex-1 truncate">{{ u.name }}</span>
<Spinner v-if="inviteBusyId === u.id" />
<Icon v-else name="ph:plus-bold" class="size-4 shrink-0 text-muted-foreground" />
</button>
<p v-if="!inviteCandidates.length" class="px-3 py-4 text-center text-xs text-muted-foreground">
{{ $t('dm.noUsers') }}
</p>
</div>
</DialogContent>
</Dialog>
<!-- Gruppe bearbeiten: Bild + Farbe -->
<Dialog v-model:open="editDialogOpen">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{{ $t('dm.editGroup') }}</DialogTitle>
</DialogHeader>
<div class="space-y-1.5">
<span class="text-sm font-medium">{{ $t('dm.groupAvatar') }}</span>
<div v-if="group?.avatarUrl && !avatarFile" class="mb-2 flex items-center gap-3">
<img :src="group.avatarUrl" :alt="groupTitle" class="size-12 rounded-sm object-cover">
<span class="text-xs text-muted-foreground">{{ $t('settings.currentAvatar') }}</span>
</div>
<FileDropzone v-model="avatarFile" accept="image/*" :hint="t('upload.avatarHint', { n: limits.avatarMaxSizeMb })" />
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium">{{ $t('dm.groupColor') }}</span>
<ColorPicker
:value="((editColor ?? '#888888') as `#${string}`)"
type="hex"
@value-change="(val: any) => editColor = val.hex"
>
<button
type="button"
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: editColor ?? 'transparent' }"
:aria-label="$t('settings.pickColor')"
/>
</ColorPicker>
<span class="truncate text-sm" :style="editColor ? { color: editColor } : undefined">{{ groupTitle }}</span>
<button
v-if="editColor"
type="button"
class="shrink-0 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
:aria-label="$t('common.delete')"
@click="editColor = null"
>
<Icon name="ph:x-bold" class="size-4" />
</button>
</div>
<div class="flex justify-end gap-2">
<Button size="sm" variant="ghost" :disabled="editSaving" @click="editDialogOpen = false">
{{ $t('common.cancel') }}
</Button>
<Button
size="sm"
:disabled="(!avatarFile && editColor === (group?.color ?? null)) || editSaving"
@click="saveGroupEdit"
>
<Spinner v-if="editSaving" />
{{ $t('common.save') }}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</template>