feat: profile avatar upload + share_conv_keys rpc + favicon + smtp tweaks
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled

This commit is contained in:
2026-04-19 23:04:03 +02:00
parent ff5ea274b9
commit 0ca29952ba
30 changed files with 3135 additions and 37 deletions
+439
View File
@@ -0,0 +1,439 @@
// Main ChatApp component — layout shared across all variants
const Avatar = ({ chat, size = '' }) => (
<div className={`avatar ${size}`} data-color={chat.color}>
{chat.initial}
{chat.presence && <div className={`presence ${chat.presence}`}></div>}
</div>
);
const ChatItem = ({ chat, onClick }) => (
<div className={`chat-item ${chat.active ? 'active' : ''}`} onClick={onClick}>
<Avatar chat={chat} />
<div className="chat-item-body">
<div className="chat-item-name">
<span>{chat.name}</span>
<span className="chat-item-time">{chat.time}</span>
</div>
<div className="chat-item-preview">
{chat.preview}
{chat.unread && <span className="unread-dot" style={{background: 'var(--accent, #a78bfa)', display: 'inline-block'}}></span>}
</div>
</div>
</div>
);
const Reactions = ({ reactions }) => (
<div className="reactions">
{reactions.map((r, i) => (
<div key={i} className={`reaction ${r.mine ? 'mine' : ''}`}>
<span>{r.emoji}</span>
<span className="count">{r.count}</span>
</div>
))}
</div>
);
const Bubble = ({ msg }) => (
<div className={`bubble ${msg.from}`}>
{msg.text}
<span className="ts">{msg.time}{msg.read && msg.from === 'me' ? ' · Gelesen' : ''}</span>
</div>
);
const MediaBubble = ({ msg }) => (
<div className="bubble me" style={{padding: 4}}>
<div className="media-preview">
<div className="media-image">{msg.label}</div>
<div className="media-meta">
<span>{msg.filename}</span>
<span>{msg.size}</span>
</div>
</div>
<span className="ts" style={{paddingLeft: 8, paddingBottom: 4}}>{msg.time}</span>
</div>
);
const EventPill = ({ evt }) => {
const IconComp = evt.icon === 'phone' ? Icon.Phone : Icon.PhoneOff;
return (
<div className="event-row">
<div className={`event-pill ${evt.kind === 'rejected' ? 'rejected' : ''}`}>
<IconComp />
<span>{evt.text}</span>
{evt.duration && <span>· {evt.duration}</span>}
<span>· {evt.time}</span>
</div>
</div>
);
};
const TypingIndicator = () => (
<div className="bubble them" style={{padding: '10px 14px'}}>
<div className="typing"><span></span><span></span><span></span></div>
</div>
);
const MessageItem = ({ msg, showAvatar, chat, myChat }) => {
if (msg.type === 'event') return <EventPill evt={msg} />;
const wrapWithAvatar = (inner) => (
<div className={`message-row ${msg.from}`}>
{msg.from === 'them' && (
<div className="message-avatar-slot">
{showAvatar && <Avatar chat={{...chat, presence: null}} size="sm" />}
</div>
)}
<div className={`message-group ${msg.from}`}>
{inner}
</div>
{msg.from === 'me' && (
<div className="message-avatar-slot">
{showAvatar && <Avatar chat={{...myChat, presence: null}} size="sm" />}
</div>
)}
</div>
);
if (msg.type === 'typing') {
return wrapWithAvatar(<TypingIndicator />);
}
if (msg.type === 'media') {
return wrapWithAvatar(<MediaBubble msg={msg} />);
}
return wrapWithAvatar(
<>
<Bubble msg={msg} />
{msg.reactions && <Reactions reactions={msg.reactions} />}
</>
);
};
const ProfileCard = ({ chat, position }) => (
<div className="profile-card" style={{top: position.top, left: position.left}}>
<div className="profile-card-banner"></div>
<div className="profile-card-body">
<div className="profile-card-avatar" data-color={chat.color} style={{background: 'var(--accent, #a78bfa)', color: '#fff'}}>
{chat.initial}
</div>
<div className="profile-card-name">{chat.name}</div>
<div className="profile-card-handle">{chat.handle}</div>
<div className="profile-card-meta">
<div>🟢 Online · seit 2h</div>
<div>📍 Beigetreten am 14. Mär 2026</div>
<div>🎮 Zockt gerade: Valorant</div>
</div>
</div>
</div>
);
const CallOverlay = ({ chat }) => (
<div className="call-overlay">
<Avatar chat={{...chat, presence: null}} size="lg" />
<div className="call-info">
<div className="call-name">Max ruft an</div>
<div className="call-sub">Voice Call · klingelt</div>
</div>
<div className="call-actions">
<button className="call-btn decline"><Icon.PhoneOff /></button>
<button className="call-btn accept"><Icon.Phone /></button>
</div>
</div>
);
const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
const [activeChat, setActiveChat] = React.useState(CHATS[0]);
const [activeTab, setActiveTab] = React.useState('chats');
// Call state: 'none' | 'incoming' | 'active' | 'pip'
const [callScreen, setCallScreen] = React.useState('none');
const [callMode, setCallMode] = React.useState('grid'); // grid | focus | fullscreen
const [focusedId, setFocusedId] = React.useState('max');
const [callState, setCallState] = React.useState({
muted: false, sharing: false, video: false, duration: '00:12:47'
});
// Esc exits fullscreen
React.useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape' && callScreen === 'active' && callMode === 'fullscreen') {
setCallMode('grid');
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [callScreen, callMode]);
const chats = CHATS.map(c => ({...c, active: c.id === activeChat.id}));
const messagesEndRef = React.useRef(null);
React.useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.parentElement.scrollTop = messagesEndRef.current.parentElement.scrollHeight;
}
}, []);
return (
<div className={`variant variant-${variant.baseKey || variant.key} ${isDark ? 'dark' : ''} ${variant.layout === 'rail' ? 'layout-rail' : 'layout-topnav'} active`} data-screen-label={`0${variant.idx+1} ${variant.label}`}>
<div className="app-window" style={{position: 'relative'}}>
<div className="window-bar">
<div className="dots">
<div className="dot" style={{background: '#ff5f57'}}></div>
<div className="dot" style={{background: '#febc2e'}}></div>
<div className="dot" style={{background: '#28c840'}}></div>
</div>
<div className="title">ChatApp {variant.label}</div>
<div style={{width: 52}}></div>
</div>
<div className="app-body">
{variant.layout === 'rail' ? (
<div className="rail-layout">
{/* Icon rail left */}
<div className="icon-rail">
<div className="rail-brand" title="Netralax"><Icon.Logo /></div>
<div className="rail-divider"></div>
<button className={`rail-btn ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')} title="Chats">
<Icon.Chat />
{activeTab === 'chats' && <div className="rail-pill"></div>}
</button>
<button className={`rail-btn ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')} title="Freunde">
<Icon.Friends />
{activeTab === 'friends' && <div className="rail-pill"></div>}
</button>
<button className={`rail-btn ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')} title="Admin">
<Icon.Shield />
{activeTab === 'admin' && <div className="rail-pill"></div>}
</button>
<div style={{flex: 1}}></div>
<button className={`rail-btn ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')} title="Einstellungen">
<Icon.Settings />
</button>
<button className="rail-btn" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'}>
{isDark ? <Icon.Sun /> : <Icon.Moon />}
</button>
</div>
{/* Chatlist + user profile at bottom */}
<div className="chat-list rail-chatlist">
<div className="chat-list-header">
<div className="chat-list-title">Chats</div>
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
</div>
<input className="chat-search" placeholder="Suche…" />
<div className="chat-items">
{chats.map(c => (
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
))}
</div>
<div className="rail-user">
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
<div style={{fontSize: 12, flex: 1, minWidth: 0}}>
<div style={{fontWeight: 600}}>dennis</div>
<div style={{opacity: 0.6, fontSize: 10}}> Online</div>
</div>
<button className="icon-btn" title="Mute"><Icon.Mic /></button>
<button className="icon-btn" title="Einstellungen"><Icon.Settings /></button>
</div>
</div>
{/* Chat area (unchanged below) */}
<div className="chat-area">
{!(callScreen === 'active' && callMode !== 'fullscreen') && (
<div className="chat-header">
<Avatar chat={activeChat} />
<div className="chat-header-info">
<div className="chat-header-name">{activeChat.name}</div>
<div className="chat-header-status">
{activeChat.presence === 'online' ? '● Online' :
activeChat.presence === 'idle' ? '● Abwesend' :
activeChat.presence === 'dnd' ? '● Nicht stören' :
'● Offline · zuletzt vor 2h'}
{' · '}{activeChat.handle}
</div>
</div>
<div className="chat-header-actions">
<button className="icon-btn" title="Suche"><Icon.Search /></button>
<button className="icon-btn" title="Voice" onClick={() => setCallScreen('active')}><Icon.Phone /></button>
<button className="icon-btn" title="Video" onClick={() => { setCallState(s => ({...s, video: true})); setCallScreen('active'); }}><Icon.Video /></button>
</div>
</div>
)}
{/* Call dock — rendered above messages, Discord-style */}
{callScreen === 'active' && callMode !== 'fullscreen' && (
<div className="call-dock">
<ActiveCallScreen
participants={CALL_PARTICIPANTS}
mode={callMode}
setMode={setCallMode}
focused={focusedId}
setFocused={setFocusedId}
onHangup={() => setCallScreen('none')}
callState={callState}
setCallState={setCallState}
/>
</div>
)}
<div className="messages">
{MESSAGES.map((m, i) => {
const next = MESSAGES[i+1];
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
const myChat = { initial: 'D', color: 'teal' };
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
})}
<div ref={messagesEndRef} />
</div>
<div className="composer">
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
<div className="composer-box">
<input placeholder="Nachricht schreiben…" />
<button className="icon-btn"><Icon.Smile /></button>
<button className="icon-btn"><Icon.Mic /></button>
</div>
<button className="composer-btn"><Icon.Send /></button>
</div>
</div>
</div>
) : (
<>
<div className="top-nav">
<div className="nav-brand">
<Icon.Logo />
<span>Netralax</span>
</div>
<div className="nav-tabs">
<button className={`nav-tab ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')}>
<Icon.Chat /> Chats
</button>
<button className={`nav-tab ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')}>
<Icon.Friends /> Freunde
</button>
<button className={`nav-tab ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')}>
<Icon.Settings /> Einstellungen
</button>
<button className={`nav-tab ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')}>
<Icon.Shield /> Admin
</button>
</div>
<div className="nav-user">
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
<div style={{fontSize: 12}}>
<div style={{fontWeight: 600}}>dennis</div>
<div style={{opacity: 0.6, fontSize: 10}}>@dennis</div>
</div>
<Icon.Chevron />
</div>
{variant.key === 'clean' && (
<button className="icon-btn theme-toggle" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'} style={{marginLeft: 6}}>
{isDark ? <Icon.Sun /> : <Icon.Moon />}
</button>
)}
</div>
<div className="main-area">
<div className="chat-list">
<div className="chat-list-header">
<div className="chat-list-title">Chats</div>
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
</div>
<input className="chat-search" placeholder="Suche…" />
<div className="chat-items">
{chats.map(c => (
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
))}
</div>
</div>
<div className="chat-area">
<div className="chat-header">
<Avatar chat={activeChat} />
<div className="chat-header-info">
<div className="chat-header-name">{activeChat.name}</div>
<div className="chat-header-status">
{activeChat.presence === 'online' ? '● Online' :
activeChat.presence === 'idle' ? '● Abwesend' :
activeChat.presence === 'dnd' ? '● Nicht stören' :
'● Offline · zuletzt vor 2h'}
{' · '}{activeChat.handle}
</div>
</div>
<div className="chat-header-actions">
<button className="icon-btn" title="Suche"><Icon.Search /></button>
<button className="icon-btn" title="Voice"><Icon.Phone /></button>
<button className="icon-btn" title="Video"><Icon.Video /></button>
</div>
</div>
<div className="messages">
{MESSAGES.map((m, i) => {
const next = MESSAGES[i+1];
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
const myChat = { initial: 'D', color: 'teal' };
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
})}
<div ref={messagesEndRef} />
</div>
<div className="composer">
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
<div className="composer-box">
<input placeholder="Nachricht schreiben…" />
<button className="icon-btn"><Icon.Smile /></button>
<button className="icon-btn"><Icon.Mic /></button>
</div>
<button className="composer-btn"><Icon.Send /></button>
</div>
</div>
</div>
</>
)}
</div>
{/* Call screen switcher — only in Clean Rail */}
{variant.layout === 'rail' && (
<div className="screen-switcher">
<button className={callScreen === 'none' ? 'active' : ''} onClick={() => setCallScreen('none')}>Chat</button>
<button className={callScreen === 'incoming' ? 'active' : ''} onClick={() => setCallScreen('incoming')}>Incoming</button>
<button className={callScreen === 'active' ? 'active' : ''} onClick={() => setCallScreen('active')}>In-Call</button>
<button className={callScreen === 'pip' ? 'active' : ''} onClick={() => setCallScreen('pip')}>PiP</button>
</div>
)}
{/* Incoming call is a full overlay */}
{callScreen === 'incoming' && (
<div className="call-screen">
<IncomingCallScreen
chat={activeChat}
onAccept={() => setCallScreen('active')}
onDecline={() => setCallScreen('none')}
/>
</div>
)}
{/* Fullscreen call hides chat entirely */}
{callScreen === 'active' && callMode === 'fullscreen' && (
<div className="call-screen">
<ActiveCallScreen
participants={CALL_PARTICIPANTS}
mode={callMode}
setMode={setCallMode}
focused={focusedId}
setFocused={setFocusedId}
onHangup={() => setCallScreen('none')}
callState={callState}
setCallState={setCallState}
/>
</div>
)}
{callScreen === 'pip' && (
<PipCall
participants={CALL_PARTICIPANTS}
onExpand={() => setCallScreen('active')}
onHangup={() => setCallScreen('none')}
/>
)}
{showCall && callScreen === 'none' && <CallOverlay chat={activeChat} />}
</div>
</div>
);
};
Object.assign(window, { ChatApp, Avatar, ChatItem, MessageItem, CallOverlay });
+194
View File
@@ -0,0 +1,194 @@
// Call UI components — Discord-style, matched to Clean-Rail aesthetic
const CALL_PARTICIPANTS = [
{ id: 'dennis', name: 'dennis', initial: 'D', color: 'teal', speaking: false, muted: false, video: false, me: true, e2ee: true },
{ id: 'max', name: 'Max', initial: 'M', color: 'teal', speaking: true, muted: false, video: true, sharing: true, e2ee: true },
{ id: 'lena', name: 'Lena', initial: 'L', color: 'amber', speaking: false, muted: true, video: false, e2ee: true },
{ id: 'tom', name: 'Tom', initial: 'T', color: 'violet', speaking: false, muted: false, video: false, e2ee: true },
{ id: 'nico', name: 'nico', initial: 'N', color: 'teal', speaking: true, muted: false, video: false, e2ee: true },
];
const ParticipantTile = ({ p, focused, onClick, small }) => (
<div className={`participant-tile ${p.speaking ? 'speaking' : ''} ${focused ? 'focused' : ''} ${small ? 'small' : ''}`} onClick={onClick}>
{p.video || p.sharing ? (
<div className="participant-video">
{p.sharing ? (
<div className="screenshare-stub">
<div className="ss-window">
<div className="ss-chrome"><span></span><span></span><span></span></div>
<div className="ss-content">
<div className="ss-line" style={{width: '60%'}}></div>
<div className="ss-line" style={{width: '80%'}}></div>
<div className="ss-line" style={{width: '40%'}}></div>
<div className="ss-block"></div>
<div className="ss-line" style={{width: '70%'}}></div>
</div>
</div>
<div className="ss-badge">
<Icon.Monitor /> {p.name} teilt Bildschirm
</div>
</div>
) : (
<div className="video-stub">
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
</div>
)}
</div>
) : (
<div className="participant-audio">
<div className={`audio-ring ${p.speaking ? 'pulse' : ''}`}>
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
</div>
</div>
)}
<div className="participant-meta">
<div className="participant-name">
{p.me && <Icon.Crown />}
<span>{p.name}{p.me ? ' (du)' : ''}</span>
{p.e2ee && <span className="e2ee-dot" title="End-to-End verschlüsselt"><Icon.Lock /></span>}
</div>
<div className="participant-icons">
{p.muted && <span className="pi muted"><Icon.MicOff /></span>}
{p.sharing && <span className="pi sharing"><Icon.Monitor /></span>}
</div>
</div>
</div>
);
const CallControls = ({ muted, onToggleMute, sharing, onToggleShare, video, onToggleVideo, onHangup, compact }) => (
<div className={`call-controls ${compact ? 'compact' : ''}`}>
<button className={`cc-btn ${muted ? 'active-danger' : ''}`} onClick={onToggleMute} title={muted ? 'Unmute' : 'Mute'}>
{muted ? <Icon.MicOff /> : <Icon.Mic />}
</button>
<button className={`cc-btn ${video ? 'active-brand' : ''}`} onClick={onToggleVideo} title="Video">
<Icon.Video />
</button>
<button className={`cc-btn ${sharing ? 'active-brand' : ''}`} onClick={onToggleShare} title="Bildschirm teilen">
<Icon.Monitor />
</button>
<button className="cc-btn" title="Teilnehmer"><Icon.Friends /></button>
<button className="cc-btn hangup" onClick={onHangup} title="Auflegen"><Icon.PhoneOff /></button>
</div>
);
const IncomingCallScreen = ({ chat, onAccept, onDecline }) => (
<div className="incoming-call">
<div className="incoming-card">
<div className="incoming-ringing">Eingehender Anruf</div>
<div className="incoming-avatar">
<div className="pulse-ring"></div>
<div className="pulse-ring d2"></div>
<Avatar chat={{initial: chat.initial, color: chat.color}} size="lg" />
</div>
<div className="incoming-name">{chat.name}</div>
<div className="incoming-sub">
<Icon.Lock /> E2E verschlüsselt · Voice Call
</div>
<div className="incoming-actions">
<button className="incoming-btn decline" onClick={onDecline}>
<Icon.PhoneOff /> Ablehnen
</button>
<button className="incoming-btn accept" onClick={onAccept}>
<Icon.Phone /> Annehmen
</button>
</div>
</div>
</div>
);
const ActiveCallScreen = ({ participants, mode, setMode, focused, setFocused, onHangup, callState, setCallState }) => {
const speaker = participants.find(p => p.id === focused) || participants.find(p => p.sharing) || participants[0];
const others = participants.filter(p => p.id !== speaker.id);
const sharingParticipant = participants.find(p => p.sharing);
return (
<div className={`active-call mode-${mode}`}>
<div className="call-topbar">
<div className="call-topbar-left">
<div className="call-title">
<Icon.Friends />
<span>Die Squad</span>
<span className="call-dot">·</span>
<span className="call-duration">{callState.duration}</span>
</div>
<div className="call-e2ee"><Icon.Lock /> E2E verschlüsselt</div>
</div>
<div className="call-topbar-right">
<button className={`cc-btn sm ${mode === 'grid' ? 'active-brand' : ''}`} onClick={() => setMode('grid')} title="Grid"><Icon.Grid /></button>
<button className={`cc-btn sm ${mode === 'focus' ? 'active-brand' : ''}`} onClick={() => setMode('focus')} title="Fokus"><Icon.Focus /></button>
<button className={`cc-btn sm ${mode === 'fullscreen' ? 'active-brand' : ''}`} onClick={() => setMode('fullscreen')} title="Vollbild"><Icon.Maximize /></button>
</div>
</div>
<div className="call-stage">
{mode === 'grid' && (
<div className={`grid grid-${participants.length}`}>
{participants.map(p => (
<ParticipantTile key={p.id} p={p} onClick={() => { setFocused(p.id); setMode('focus'); }} />
))}
</div>
)}
{mode === 'focus' && (
<div className="focus-layout">
<div className="focus-main">
<ParticipantTile p={speaker} focused />
</div>
<div className="focus-strip">
{others.map(p => (
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
))}
</div>
</div>
)}
{mode === 'fullscreen' && (
<div className="fullscreen-layout">
<ParticipantTile p={speaker} focused />
<div className="fs-strip">
{others.slice(0,4).map(p => (
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
))}
</div>
<div className="fs-hint">Esc zum Verlassen</div>
</div>
)}
</div>
<CallControls
muted={callState.muted}
onToggleMute={() => setCallState(s => ({...s, muted: !s.muted}))}
sharing={callState.sharing}
onToggleShare={() => setCallState(s => ({...s, sharing: !s.sharing}))}
video={callState.video}
onToggleVideo={() => setCallState(s => ({...s, video: !s.video}))}
onHangup={onHangup}
/>
</div>
);
};
const PipCall = ({ onExpand, onHangup, participants }) => {
const speaker = participants.find(p => p.sharing) || participants.find(p => p.speaking) || participants[0];
return (
<div className="pip-call" onClick={onExpand}>
<div className="pip-preview">
{speaker.sharing ? (
<div className="screenshare-stub small"><div className="ss-window mini"></div></div>
) : (
<Avatar chat={{initial: speaker.initial, color: speaker.color}} size="sm" />
)}
</div>
<div className="pip-info">
<div className="pip-title">Die Squad · {participants.length}</div>
<div className="pip-sub">
<span className="live-dot"></span>
Live · tippe zum Öffnen
</div>
</div>
<button className="pip-hangup" onClick={(e) => { e.stopPropagation(); onHangup(); }}>
<Icon.PhoneOff />
</button>
</div>
);
};
Object.assign(window, { CALL_PARTICIPANTS, ParticipantTile, CallControls, IncomingCallScreen, ActiveCallScreen, PipCall });
@@ -0,0 +1,28 @@
// Sample data
const CHATS = [
{ id: 'test', name: 'test', handle: '@test', color: 'violet', initial: 'T', preview: 'Hallo 👋', time: '21:13', unread: false, presence: 'offline', active: true },
{ id: 'max', name: 'Max', handle: '@maxx', color: 'teal', initial: 'M', preview: 'bro du musst das sehen', time: '20:44', unread: true, presence: 'online' },
{ id: 'squad', name: 'Die Squad', handle: '5 Mitglieder', color: 'rose', initial: 'S', preview: 'leon: bin gleich da', time: '19:12', unread: true, presence: 'online', isGroup: true },
{ id: 'lena', name: 'Lena', handle: '@lenae', color: 'amber', initial: 'L', preview: 'Du: ok passt', time: 'Gestern', unread: false, presence: 'idle' },
{ id: 'tom', name: 'Tom', handle: '@tomg', color: 'violet', initial: 'T', preview: 'sent a photo', time: 'Gestern', unread: false, presence: 'dnd' },
{ id: 'nico', name: 'nico', handle: '@nicoo', color: 'teal', initial: 'N', preview: 'gg wp', time: 'Mo.', unread: false, presence: 'offline' },
];
const MESSAGES = [
{ type: 'message', from: 'them', text: 'Hi', time: '20:12' },
{ type: 'message', from: 'them', text: 'Lol', time: '20:14' },
{ type: 'message', from: 'them', text: 'Wie gehts', time: '20:24' },
{ type: 'message', from: 'them', text: 'einer da?', time: '20:25' },
{ type: 'message', from: 'them', text: 'Hallo', time: '21:03', reactions: [{emoji: '👋', count: 1, mine: true}] },
{ type: 'event', kind: 'call', icon: 'phone', text: 'Eingehender Anruf', duration: '1:01', time: '21:06' },
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
{ type: 'message', from: 'me', text: 'sry war afk', time: '21:09', reactions: [{emoji: '😅', count: 2}, {emoji: '❤️', count: 1, mine: true}] },
{ type: 'media', from: 'me', filename: 'IMG_4120.jpg', size: '2.4 MB', label: '[ PLACEHOLDER IMAGE ]', time: '21:10' },
{ type: 'message', from: 'them', text: 'pushhh sick', time: '21:12' },
{ type: 'message', from: 'me', text: 'Hallo', time: '21:13', read: true },
{ type: 'typing', from: 'them' },
];
Object.assign(window, { CHATS, MESSAGES });
@@ -0,0 +1,87 @@
// Shared SVG icon components
const Icon = {
Logo: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M4 6 L12 2 L20 6 L20 18 L12 22 L4 18 Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
<path d="M4 6 L12 10 L20 6" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
<path d="M12 10 L12 22" stroke="currentColor" strokeWidth="1.8"/>
</svg>
),
Chat: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
),
Friends: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
),
Settings: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
),
Shield: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
),
Phone: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
),
PhoneOff: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="23" y1="1" x2="1" y2="23"/></svg>
),
Video: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
),
Search: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
),
Plus: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
),
Send: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
),
AddUser: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
),
Chevron: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
),
Paperclip: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
),
Smile: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
),
Mic: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
),
Sun: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
),
Moon: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
),
MicOff: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
),
Monitor: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
),
Lock: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
),
Crown: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16l-2-8 5 4 4-7 4 7 5-4-2 8z"/></svg>
),
Grid: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
),
Focus: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="12" rx="1"/><rect x="3" y="18" width="4" height="3" rx="0.5"/><rect x="9" y="18" width="4" height="3" rx="0.5"/><rect x="15" y="18" width="4" height="3" rx="0.5"/></svg>
),
Maximize: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
),
Minimize: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg>
),
};
Object.assign(window, { Icon });