Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1275 lines
49 KiB
Vue
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 }} %</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>
|