Initial React project
This commit is contained in:
461
src/presentation/messages/pages/MessagesPage.tsx
Normal file
461
src/presentation/messages/pages/MessagesPage.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user