feat: profile avatar upload + share_conv_keys rpc + favicon + smtp tweaks
This commit is contained in:
@@ -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 });
|
||||
Reference in New Issue
Block a user