Files
Arbejd.com-react/src/presentation/messages/pages/MessagesPage.tsx
2026-03-03 00:56:54 +01:00

462 lines
17 KiB
TypeScript

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<string | undefined>(undefined);
const [threads, setThreads] = useState<MessageThreadItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ThreadFilter>('all');
const [activeThreadId, setActiveThreadId] = useState<string>('');
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 (
<section className={`dash-root ${theme === 'dark' ? 'theme-dark' : ''}`}>
<div className="dash-orb dash-orb-1" />
<div className="dash-orb dash-orb-2" />
<div className="dash-orb dash-orb-3" />
<DashboardSidebar active="messages" onNavigate={onNavigate} />
<main className="dash-main custom-scrollbar msg-main">
<DashboardTopbar
name={name}
imageUrl={imageUrl}
onLogout={onLogout}
theme={theme}
onToggleTheme={onToggleTheme}
/>
<div className="msg-head">
<div>
<h1>Beskeder</h1>
<p>Kommuniker med virksomheder og hold styr dine ansøgninger.</p>
</div>
<button type="button" className="msg-mark-btn" onClick={() => void handleMarkAllRead()}>
<CheckCheck size={16} strokeWidth={1.8} /> Marker alle som læst
</button>
</div>
<div className="msg-layout">
<section className="msg-threads">
<div className="msg-threads-head">
<div className="msg-search-wrap">
<Search size={16} strokeWidth={1.8} />
<input value={search} onChange={(event) => setSearch(event.target.value)} type="text" placeholder="Søg i beskeder..." />
</div>
<div className="msg-filter-row">
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
<button type="button" className={filter === 'unread' ? 'active' : ''} onClick={() => setFilter('unread')}>Ulæste</button>
<button type="button" className={filter === 'companies' ? 'active' : ''} onClick={() => setFilter('companies')}>Virksomheder</button>
</div>
</div>
<div className="msg-thread-list custom-scrollbar">
{isLoading ? <p className="dash-loading">Indlaeser beskeder...</p> : null}
{!isLoading && filteredThreads.length === 0 ? <p className="dash-loading">Ingen tråde fundet.</p> : null}
{filteredThreads.map((thread) => {
const unread = threadUnreadCount(thread);
const isActive = activeThread?.id === thread.id;
const avatar = threadAvatar(thread);
return (
<button type="button" key={thread.id} className={isActive ? 'msg-thread-item active' : 'msg-thread-item'} onClick={() => void handleSelectThread(thread)}>
<div className="msg-thread-avatar-wrap">
{avatar ? <img src={avatar} alt={thread.companyName} className="msg-thread-avatar" /> : <div className="msg-thread-avatar-fallback">{thread.companyName.slice(0, 1).toUpperCase()}</div>}
<span className="msg-thread-online" />
</div>
<div className="msg-thread-content">
<div className="msg-thread-row">
<h3>{thread.companyName}</h3>
<span>{formatThreadDate(thread.latestMessage?.timeSent)}</span>
</div>
<p className={unread > 0 ? 'unread' : ''}>{thread.latestMessage?.text || 'Ingen beskeder endnu'}</p>
<small>{thread.title || 'Stilling'}</small>
</div>
{unread > 0 ? <div className="msg-thread-unread">{unread}</div> : null}
</button>
);
})}
</div>
</section>
<section className="msg-chat">
<div className="msg-chat-head">
{activeThread ? (
<>
<div className="msg-chat-company">
{threadAvatar(activeThread)
? <img src={threadAvatar(activeThread)} alt={activeThread.companyName} className="msg-chat-avatar" />
: <div className="msg-chat-avatar-fallback">{activeThread.companyName.slice(0, 1).toUpperCase()}</div>}
<div>
<h2>{activeThread.companyName}</h2>
<p>{activeThread.title || 'Rekruttering'}</p>
</div>
</div>
<div className="msg-chat-actions">
<button type="button" aria-label="Ring"><Phone size={16} strokeWidth={1.8} /></button>
<button type="button" aria-label="Info"><Info size={16} strokeWidth={1.8} /></button>
</div>
</>
) : (
<h2>Vælg en samtale</h2>
)}
</div>
<div className="msg-chat-body custom-scrollbar">
{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 (
<div key={`${message.threadId}-${index}`}>
{showDay ? <div className="msg-day-sep">{dayLabel(currentTime)}</div> : null}
<div className={message.fromCandidate ? 'msg-bubble-row mine' : 'msg-bubble-row'}>
{!message.fromCandidate ? (
threadAvatar(activeThread as MessageThreadItem)
? <img src={threadAvatar(activeThread as MessageThreadItem)} alt={(activeThread as MessageThreadItem).companyName} className="msg-mini-avatar" />
: <div className="msg-mini-avatar msg-mini-avatar-fallback">{(activeThread as MessageThreadItem).companyName.slice(0, 1).toUpperCase()}</div>
) : null}
<div className="msg-bubble-wrap">
<span className="msg-time">{formatTime(message.timeSent)}</span>
<div className={message.fromCandidate ? 'msg-bubble mine' : 'msg-bubble'}>{message.text}</div>
</div>
</div>
</div>
);
})}
</div>
<div className="msg-input-area">
<div className="msg-input-wrap">
<button type="button" aria-label="Vedhæft"><Paperclip size={18} strokeWidth={1.8} /></button>
<textarea
rows={1}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSendMessage();
}
}}
placeholder="Skriv din besked her..."
/>
<button type="button" aria-label="Emoji"><Smile size={18} strokeWidth={1.8} /></button>
<button type="button" className="msg-send-btn" onClick={() => void handleSendMessage()}>
Send <Send size={15} strokeWidth={1.8} />
</button>
</div>
</div>
</section>
</div>
</main>
</section>
);
}