462 lines
17 KiB
TypeScript
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 på 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>
|
|
);
|
|
}
|