a04ecf7a19
- Voice messages: MediaRecorder → encrypted attachment, custom waveform player via OfflineAudioContext, 60s limit + live mic-level meter - Offline message queue: localStorage outbox, exponential backoff retries, optimistic pending bubble with retry/discard - Delivery indicator: message_deliveries table + RLS (reciprocal receipts), ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack) - Per-participant volume slider in calls via right-click tile menu, persisted to localStorage, applied to attached audio elements - Group call scaling: grid up to 12 tiles with pagination, active-speaker auto-promotion in fullscreen - Push notifications scaffolding: service worker, VAPID subscription registration, notify-push edge function skeleton - Backup recovery code: 24-char base32 code (~120 bits entropy) as alternative decrypt path, restore UI with mode toggle - Admin panel: conversations list, audit log (admin_audit_log table + admin_log_action RPC), audit entry on user flag toggle - Search v2: sender filter, attachment-only toggle, date range - Reactions pop animation (scale 0.4→1.15→1 on count change) - Message list windowing (150 default, expand via IntersectionObserver) - Stub cleanup: removed dead ScreenshareStub from CallParticipantTile Fixes: - Focus-triggered flicker: dropped window.focus listeners in three spots, throttled visibilitychange/online wake-refreshes to 30s, keep existing data visible during background re-syncs (no more spinner on every click) - Voice attachment audio element collapsed to 0px on peer side — now forces 280px min-width on bubble Migrations (push required): 20260421000001_message_deliveries.sql 20260421000002_admin_audit_log.sql Server TODO: VAPID keys + notify-push edge function deploy
452 lines
20 KiB
React
452 lines
20 KiB
React
// 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 ${activeTab !== 'chats' ? 'page-mode' : ''}`}>
|
|
{/* 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 — only on chats tab */}
|
|
{activeTab === 'chats' && (
|
|
<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 OR page content */}
|
|
<div className={`chat-area ${activeTab !== 'chats' ? 'has-page' : ''}`}>
|
|
{activeTab === 'friends' && <FriendsPageContent />}
|
|
{activeTab === 'admin' && <AdminPageContent />}
|
|
{activeTab === 'settings' && <SettingsPageContent isDark={isDark} onToggleDark={onToggleDark} />}
|
|
{activeTab === 'chats' && <>
|
|
{!(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">
|
|
{activeTab === 'friends' && <div className="chat-area has-page" style={{width: '100%'}}><FriendsPageContent /></div>}
|
|
{activeTab === 'admin' && <div className="chat-area has-page" style={{width: '100%'}}><AdminPageContent /></div>}
|
|
{activeTab === 'settings' && <div className="chat-area has-page" style={{width: '100%'}}><SettingsPageContent isDark={isDark} onToggleDark={onToggleDark} /></div>}
|
|
{activeTab === 'chats' && <>
|
|
<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, only on chats tab */}
|
|
{variant.layout === 'rail' && activeTab === 'chats' && (
|
|
<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 });
|