import { useEffect, useMemo, useState } from 'react'; import { CheckCheck, Info, Paperclip, Phone, Search, Send, Smile, } from 'lucide-react'; import type { ChatMessageInterface } from '../../../mvvm/models/chat-message.interface'; import { MessagesViewModel, type MessageThreadItem } from '../../../mvvm/viewmodels/MessagesViewModel'; import { DashboardSidebar, type DashboardNavKey } from '../../dashboard/components/DashboardSidebar'; import { DashboardTopbar } from '../../dashboard/components/DashboardTopbar'; import '../../dashboard/pages/dashboard.css'; import './messages.css'; interface MessagesPageProps { onLogout: () => void; onNavigate: (target: DashboardNavKey) => void; onToggleTheme: () => void; theme: 'light' | 'dark'; } type ThreadFilter = 'all' | 'unread' | 'companies'; function toMillis(value?: Date | string): number { if (!value) { return 0; } const date = value instanceof Date ? value : new Date(value); const millis = date.getTime(); return Number.isNaN(millis) ? 0 : millis; } function formatTime(value?: Date | string): string { if (!value) { return '--:--'; } const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) { return '--:--'; } return new Intl.DateTimeFormat('da-DK', { hour: '2-digit', minute: '2-digit' }).format(date); } function formatThreadDate(value?: Date | string): string { if (!value) { return ''; } const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) { return ''; } const now = new Date(); const dayMs = 24 * 60 * 60 * 1000; const diffDays = Math.floor((new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() - new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()) / dayMs); if (diffDays === 0) { return formatTime(date); } if (diffDays === 1) { return 'I går'; } return new Intl.DateTimeFormat('da-DK', { day: '2-digit', month: 'short' }).format(date); } function dayLabel(value: Date): string { const now = new Date(); const dateOnly = new Date(value.getFullYear(), value.getMonth(), value.getDate()); const nowOnly = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const diff = Math.floor((nowOnly.getTime() - dateOnly.getTime()) / (24 * 60 * 60 * 1000)); if (diff === 0) { return 'I dag'; } if (diff === 1) { return 'I går'; } return new Intl.DateTimeFormat('da-DK', { day: '2-digit', month: 'short' }).format(value); } function isUnreadMessage(message: ChatMessageInterface): boolean { return !message.fromCandidate && !message.seen; } function threadUnreadCount(thread: MessageThreadItem): number { return thread.allMessages.filter(isUnreadMessage).length; } function threadAvatar(thread: MessageThreadItem): string { return thread.companyLogoUrl || thread.companyLogo || ''; } function normalizeThread(thread: MessageThreadItem): MessageThreadItem { return { ...thread, allMessages: [...(thread.allMessages ?? [])].sort((a, b) => toMillis(a.timeSent) - toMillis(b.timeSent)), }; } function fallbackThreads(): MessageThreadItem[] { const now = new Date(); const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000); const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); const makeMessage = (threadId: string, text: string, fromCandidate: boolean, when: Date, seen?: Date): ChatMessageInterface => ({ threadId, text, fromCandidate, timeSent: when, seen, }); const t1Messages = [ makeMessage('thread-techcorp', 'Hej Lasse! Mange tak for din ansøgning.', false, twoHoursAgo), makeMessage('thread-techcorp', 'Mange tak, det lyder rigtig spændende.', true, new Date(twoHoursAgo.getTime() + 20 * 60 * 1000), new Date(twoHoursAgo.getTime() + 30 * 60 * 1000)), makeMessage('thread-techcorp', 'Vi vil gerne invitere dig til samtale.', false, tenMinutesAgo), ]; const t2Messages = [ makeMessage('thread-lunar', 'Mange tak for din opdaterede portefølje.', false, new Date(now.getTime() - 26 * 60 * 60 * 1000), new Date(now.getTime() - 25 * 60 * 60 * 1000)), ]; return [ { id: 'thread-techcorp', companyLogo: '', companyLogoUrl: 'https://i.pravatar.cc/150?img=33', companyName: 'TechCorp A/S', candidateFirstName: 'Lasse', candidateLastName: 'Hansen', candidateImage: 'https://i.pravatar.cc/150?img=11', allMessages: t1Messages, latestMessage: t1Messages[t1Messages.length - 1], title: 'Frontend Udvikler', messagesLoaded: true, jobPostingId: 'job-1', jobPosting: undefined as never, isFromSupport: false, }, { id: 'thread-lunar', companyLogo: '', companyLogoUrl: 'https://i.pravatar.cc/150?img=12', companyName: 'Lunar Bank', candidateFirstName: 'Lasse', candidateLastName: 'Hansen', candidateImage: 'https://i.pravatar.cc/150?img=11', allMessages: t2Messages, latestMessage: t2Messages[t2Messages.length - 1], title: 'Senior UX Designer', messagesLoaded: true, jobPostingId: 'job-2', jobPosting: undefined as never, isFromSupport: false, }, ]; } export function MessagesPage({ onLogout, onNavigate, onToggleTheme, theme }: MessagesPageProps) { const viewModel = useMemo(() => new MessagesViewModel(), []); const [name, setName] = useState('Lasse'); const [imageUrl, setImageUrl] = useState(undefined); const [threads, setThreads] = useState([]); const [isLoading, setIsLoading] = useState(true); const [search, setSearch] = useState(''); const [filter, setFilter] = useState('all'); const [activeThreadId, setActiveThreadId] = useState(''); const [draft, setDraft] = useState(''); useEffect(() => { let active = true; async function loadData() { setIsLoading(true); try { const profile = await viewModel.getCandidateProfile(); if (active) { setName(profile.name); setImageUrl(profile.imageUrl); } const loadedThreads = await viewModel.getThreads(); if (!active) { return; } const normalized = (loadedThreads.length > 0 ? loadedThreads : fallbackThreads()).map(normalizeThread); setThreads(normalized); setActiveThreadId(normalized[0]?.id || ''); } catch { if (!active) { return; } const fallback = fallbackThreads(); setThreads(fallback); setActiveThreadId(fallback[0]?.id || ''); } finally { if (active) { setIsLoading(false); } } } void loadData(); return () => { active = false; }; }, [viewModel]); const filteredThreads = useMemo(() => { const term = search.trim().toLowerCase(); return threads.filter((thread) => { if (filter === 'unread' && threadUnreadCount(thread) === 0) { return false; } if (filter === 'companies' && thread.isFromSupport) { return false; } if (!term) { return true; } return thread.companyName.toLowerCase().includes(term) || (thread.latestMessage?.text || '').toLowerCase().includes(term); }); }, [filter, search, threads]); const activeThread = useMemo( () => threads.find((thread) => thread.id === activeThreadId) || filteredThreads[0], [activeThreadId, filteredThreads, threads], ); const messages = useMemo( () => [...(activeThread?.allMessages || [])].sort((a, b) => toMillis(a.timeSent) - toMillis(b.timeSent)), [activeThread], ); async function handleSelectThread(thread: MessageThreadItem) { setActiveThreadId(thread.id); const lastUnread = [...thread.allMessages].reverse().find((item) => isUnreadMessage(item)); if (lastUnread?.id) { void viewModel.markThreadReadByMessageId(lastUnread.id); setThreads((current) => current.map((entry) => { if (entry.id !== thread.id) { return entry; } return { ...entry, allMessages: entry.allMessages.map((item) => (isUnreadMessage(item) ? { ...item, seen: new Date() } : item)), }; })); } } async function handleMarkAllRead() { const latestUnread = threads .flatMap((thread) => thread.allMessages) .filter((item) => isUnreadMessage(item) && Boolean(item.id)); await Promise.all(latestUnread.map((item) => viewModel.markThreadReadByMessageId(item.id))); setThreads((current) => current.map((thread) => ({ ...thread, allMessages: thread.allMessages.map((item) => (isUnreadMessage(item) ? { ...item, seen: new Date() } : item)), }))); } async function handleSendMessage() { const text = draft.trim(); if (!activeThread || !text) { return; } const optimistic: ChatMessageInterface = { threadId: activeThread.id, text, fromCandidate: true, timeSent: new Date(), }; setDraft(''); setThreads((current) => current.map((thread) => { if (thread.id !== activeThread.id) { return thread; } const nextMessages = [...thread.allMessages, optimistic]; return { ...thread, allMessages: nextMessages, latestMessage: optimistic, }; })); try { const created = await viewModel.sendMessage(activeThread.id, text); setThreads((current) => current.map((thread) => { if (thread.id !== activeThread.id) { return thread; } const withoutOptimistic = thread.allMessages.filter((item) => item !== optimistic); const nextMessages = [...withoutOptimistic, created]; return { ...thread, allMessages: nextMessages, latestMessage: created, }; })); } catch { // Keep optimistic message visible if backend send fails. } } return (

Beskeder

Kommuniker med virksomheder og hold styr på dine ansøgninger.

setSearch(event.target.value)} type="text" placeholder="Søg i beskeder..." />
{isLoading ?

Indlaeser beskeder...

: null} {!isLoading && filteredThreads.length === 0 ?

Ingen tråde fundet.

: null} {filteredThreads.map((thread) => { const unread = threadUnreadCount(thread); const isActive = activeThread?.id === thread.id; const avatar = threadAvatar(thread); return ( ); })}
{activeThread ? ( <>
{threadAvatar(activeThread) ? {activeThread.companyName} :
{activeThread.companyName.slice(0, 1).toUpperCase()}
}

{activeThread.companyName}

{activeThread.title || 'Rekruttering'}

) : (

Vælg en samtale

)}
{messages.map((message, index) => { const currentTime = message.timeSent instanceof Date ? message.timeSent : new Date(message.timeSent); const prev = index > 0 ? messages[index - 1] : undefined; const prevTime = prev?.timeSent instanceof Date ? prev.timeSent : (prev?.timeSent ? new Date(prev.timeSent) : undefined); const showDay = !prevTime || currentTime.toDateString() !== prevTime.toDateString(); return (
{showDay ?
{dayLabel(currentTime)}
: null}
{!message.fromCandidate ? ( threadAvatar(activeThread as MessageThreadItem) ? {(activeThread :
{(activeThread as MessageThreadItem).companyName.slice(0, 1).toUpperCase()}
) : null}
{formatTime(message.timeSent)}
{message.text}
); })}