Files
BRETT/app/composables/useDirectMessages.ts
T
FliesentischKingandDevin 22639fe770 Add draggable and resizable floating direct message chat windows
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:25:41 +02:00

301 lines
11 KiB
TypeScript

import type { DmConversation, DmGroup, DmMessage, DmReactionEvent, DmUser } from '~~/shared/utils/messages'
interface DmEvent {
message: DmMessage
sender: DmUser
recipient?: DmUser | null
group?: DmGroup | null
}
interface DmReadEvent {
reader: number
partner: number
at: string
}
interface DmGroupReadEvent {
reader: number
groupId: number
at: string
}
export interface FloatingChatTab {
user: DmUser
minimized: boolean
}
type MessageListener = (msg: DmMessage, other: DmUser | null, group: DmGroup | null) => void
type ReadListener = (partnerId: number, at: string) => void
type ReactionListener = (e: DmReactionEvent) => void
// Direkt-Callbacks der offenen Chat-Ansicht - Modul-Singletons, weil
// Plugin (WS-Event) und Page (Registrierung) verschiedene Composable-Aufrufe nutzen
const liveMessageHandler = shallowRef<MessageListener | null>(null)
const liveReadHandler = shallowRef<ReadListener | null>(null)
const liveReactionHandler = shallowRef<ReactionListener | null>(null)
const liveGroupHandler = shallowRef<((group: DmGroup) => void) | null>(null)
// Zusätzliche Listener für schwebende Messenger-Fenster (mehrere gleichzeitig möglich)
const floatingMessageListeners = new Set<MessageListener>()
const floatingReadListeners = new Set<ReadListener>()
const floatingReactionListeners = new Set<ReactionListener>()
const MAX_FLOATING_CHATS = 3
export function useDirectMessages() {
const { user } = useAuth()
const conversations = useState<DmConversation[]>('dm:conversations', () => [])
const unreadTotal = useState<number>('dm:unread', () => 0)
// Aktuell geöffnete Konversation (messages-Seite setzt/löscht das)
const openPartnerId = useState<number | null>('dm:open', () => null)
const openGroupId = useState<number | null>('dm:open-group', () => null)
// Schwebende 1:1-Chat-Fenster (Messenger-Stil)
const floatingChats = useState<FloatingChatTab[]>('dm:floating', () => [])
function isPartnerOpen(partnerId: number): boolean {
if (openPartnerId.value === partnerId) return true
return floatingChats.value.some(c => c.user.id === partnerId && !c.minimized)
}
function openFloatingChat(partner: DmUser, minimized = false) {
if (!user.value || partner.id === user.value.id) return
const existing = floatingChats.value.find(c => c.user.id === partner.id)
if (existing) {
existing.user = { ...existing.user, ...partner }
existing.minimized = minimized
return
}
const next = [...floatingChats.value, { user: partner, minimized }]
if (next.length > MAX_FLOATING_CHATS) next.shift()
floatingChats.value = next
}
function toggleMinimizeFloatingChat(userId: number) {
const tab = floatingChats.value.find(c => c.user.id === userId)
if (tab) tab.minimized = !tab.minimized
}
function closeFloatingChat(userId: number) {
floatingChats.value = floatingChats.value.filter(c => c.user.id !== userId)
}
function subscribeFloating(handlers: {
onMessage?: MessageListener
onRead?: ReadListener
onReaction?: ReactionListener
}) {
if (handlers.onMessage) floatingMessageListeners.add(handlers.onMessage)
if (handlers.onRead) floatingReadListeners.add(handlers.onRead)
if (handlers.onReaction) floatingReactionListeners.add(handlers.onReaction)
return () => {
if (handlers.onMessage) floatingMessageListeners.delete(handlers.onMessage)
if (handlers.onRead) floatingReadListeners.delete(handlers.onRead)
if (handlers.onReaction) floatingReactionListeners.delete(handlers.onReaction)
}
}
function notifyMessageListeners(msg: DmMessage, other: DmUser | null, group: DmGroup | null) {
liveMessageHandler.value?.(msg, other, group)
for (const fn of floatingMessageListeners) fn(msg, other, group)
}
function notifyReadListeners(partnerId: number, at: string) {
liveReadHandler.value?.(partnerId, at)
for (const fn of floatingReadListeners) fn(partnerId, at)
}
function notifyReactionListeners(e: DmReactionEvent) {
liveReactionHandler.value?.(e)
for (const fn of floatingReactionListeners) fn(e)
}
async function fetchConversations() {
const res = await $fetch<{ conversations: DmConversation[], unreadTotal: number }>('/api/messages')
conversations.value = res.conversations
unreadTotal.value = res.unreadTotal
}
function upsertDm(other: DmUser, msg: DmMessage, unreadDelta: number) {
const existing = conversations.value.find(c => c.kind === 'dm' && c.user?.id === other.id)
conversations.value = [{
kind: 'dm',
user: existing?.user ?? other,
group: null,
lastMessage: msg,
unread: Math.max(0, (existing?.unread ?? 0) + unreadDelta),
}, ...conversations.value.filter(c => !(c.kind === 'dm' && c.user?.id === other.id))]
}
function upsertGroup(group: DmGroup, msg: DmMessage | null, unreadDelta: number) {
const existing = conversations.value.find(c => c.kind === 'group' && c.group?.id === group.id)
conversations.value = [{
kind: 'group',
user: null,
group,
lastMessage: msg ?? existing?.lastMessage ?? null,
unread: Math.max(0, (existing?.unread ?? 0) + unreadDelta),
}, ...conversations.value.filter(c => !(c.kind === 'group' && c.group?.id === group.id))]
}
// Eingehende WS-Nachricht - Unread-Badge erhöht sich nur außerhalb der offenen Konversation
function handleIncoming(event: DmEvent) {
const { message: msg } = event
if (event.group) {
const g = event.group
const isMine = msg.senderId === user.value?.id
const isOpen = openGroupId.value === g.id
upsertGroup(g, msg, isMine || isOpen ? 0 : 1)
if (!isMine && !isOpen) unreadTotal.value += 1
if (isOpen) notifyMessageListeners(msg, null, g)
return
}
const isForMe = msg.recipientId === user.value?.id
const other = isForMe ? event.sender : event.recipient!
if (isForMe) {
// Wie beim Messenger: eingehende 1:1-Nachricht poppt automatisch als
// Floating-Tab auf, sofern man nicht schon in /messages mit dem User ist
if (openPartnerId.value !== msg.senderId && !floatingChats.value.some(c => c.user.id === other.id)) {
openFloatingChat(other, false)
}
const isOpen = isPartnerOpen(msg.senderId)
upsertDm(other, msg, isOpen ? 0 : 1)
if (!isOpen) unreadTotal.value += 1
notifyMessageListeners(msg, other, null)
}
else {
// Eigene Nachricht aus anderem Tab/Fenster synchronisieren
upsertDm(other, msg, 0)
notifyMessageListeners(msg, other, null)
}
}
// Eingehende Reaction - offene Konversation direkt updaten, sonst
// Unread-Badge der Konversation (Reaction = Aktivität des Gegenübers)
function handleReaction(e: DmReactionEvent) {
const isMine = e.actorId === user.value?.id
if (e.group) {
const gid = e.group.id
if (openGroupId.value === gid) {
notifyReactionListeners(e)
return
}
if (isMine) return
const conv = conversations.value.find(c => c.kind === 'group' && c.group?.id === gid)
const delta = e.active ? 1 : -1
if (conv) conv.unread = Math.max(0, conv.unread + delta)
unreadTotal.value = Math.max(0, unreadTotal.value + delta)
return
}
const partnerId = isMine ? e.other!.id : e.actorId
notifyReactionListeners(e)
if (isPartnerOpen(partnerId) || isMine) return
const conv = conversations.value.find(c => c.kind === 'dm' && c.user?.id === partnerId)
const delta = e.active ? 1 : -1
if (conv) conv.unread = Math.max(0, conv.unread + delta)
unreadTotal.value = Math.max(0, unreadTotal.value + delta)
}
// Lesebestätigung 1:1: reader=wer gelesen hat, partner=die andere Seite
function handleRead(event: DmReadEvent) {
if (event.reader === user.value?.id) {
// Ich habe woanders gelesen → Unread dieser Konversation weg
const conv = conversations.value.find(c => c.kind === 'dm' && c.user?.id === event.partner)
const delta = conv?.unread ?? 0
if (conv) conv.unread = 0
unreadTotal.value = Math.max(0, unreadTotal.value - delta)
}
else if (event.partner === user.value?.id) {
// Gegenüber hat meine Nachrichten gelesen
notifyReadListeners(event.reader, event.at)
const conv = conversations.value.find(c => c.kind === 'dm' && c.user?.id === event.reader)
if (conv?.lastMessage && conv.lastMessage.senderId === user.value?.id && !conv.lastMessage.readAt) {
conv.lastMessage.readAt = event.at
}
}
}
// Gruppen-Lesebestätigung - nur für eigene andere Tabs relevant
function handleGroupRead(event: DmGroupReadEvent) {
if (event.reader !== user.value?.id) return
const conv = conversations.value.find(c => c.kind === 'group' && c.group?.id === event.groupId)
const delta = conv?.unread ?? 0
if (conv) conv.unread = 0
unreadTotal.value = Math.max(0, unreadTotal.value - delta)
}
// Neue Gruppe bzw. geänderte Mitgliederliste - Konversation anlegen/updaten
// und eine geöffnete Ansicht informieren
function handleGroupUpdate(group: DmGroup) {
upsertGroup(group, null, 0)
if (openGroupId.value === group.id) liveGroupHandler.value?.(group)
}
// Optimistisch: eigene gesendete Nachricht sofort in die Liste
function addOwnMessage(partner: DmUser, msg: DmMessage) {
upsertDm(partner, msg, 0)
notifyMessageListeners(msg, partner, null)
}
function addOwnGroupMessage(group: DmGroup, msg: DmMessage) {
upsertGroup(group, msg, 0)
}
function markConversationRead(partnerId: number) {
const conv = conversations.value.find(c => c.kind === 'dm' && c.user?.id === partnerId)
const delta = conv?.unread ?? 0
if (!delta) return
conv!.unread = 0
unreadTotal.value = Math.max(0, unreadTotal.value - delta)
}
function markGroupRead(groupId: number) {
const conv = conversations.value.find(c => c.kind === 'group' && c.group?.id === groupId)
const delta = conv?.unread ?? 0
if (!delta) return
conv!.unread = 0
unreadTotal.value = Math.max(0, unreadTotal.value - delta)
}
function reset() {
conversations.value = []
unreadTotal.value = 0
openPartnerId.value = null
openGroupId.value = null
floatingChats.value = []
liveMessageHandler.value = null
liveReadHandler.value = null
liveReactionHandler.value = null
liveGroupHandler.value = null
}
return {
conversations,
unreadTotal,
openPartnerId,
openGroupId,
floatingChats,
openFloatingChat,
toggleMinimizeFloatingChat,
closeFloatingChat,
subscribeFloating,
fetchConversations,
handleIncoming,
handleReaction,
handleRead,
handleGroupRead,
handleGroupUpdate,
addOwnMessage,
addOwnGroupMessage,
markConversationRead,
markGroupRead,
liveMessageHandler,
liveReadHandler,
liveReactionHandler,
liveGroupHandler,
reset,
}
}