25ed765a03
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
717 lines
37 KiB
React
717 lines
37 KiB
React
/* Modals, popovers, voice channel, login */
|
|
|
|
const EmojiPicker = ({ anchor, onPick, onClose }) => {
|
|
const [tab, setTab] = React.useState('recent');
|
|
const [q, setQ] = React.useState('');
|
|
const [hover, setHover] = React.useState(null);
|
|
const ref = React.useRef(null);
|
|
const cats = window.PYRAMID_DATA.emojiCategories;
|
|
|
|
React.useEffect(() => {
|
|
const h = (e) => {
|
|
if (ref.current && !ref.current.contains(e.target)) onClose();
|
|
};
|
|
setTimeout(() => document.addEventListener('mousedown', h), 50);
|
|
return () => document.removeEventListener('mousedown', h);
|
|
}, []);
|
|
|
|
const style = anchor ? { bottom: window.innerHeight - anchor.top + 8, right: Math.max(20, window.innerWidth - anchor.right) } : { bottom: 80, right: 20 };
|
|
|
|
const filtered = q ? cats.flatMap(c => c.emojis.filter(e => c.key.includes(q.toLowerCase()))) : null;
|
|
|
|
return (
|
|
<div className="emoji-picker" ref={ref} style={style}>
|
|
<div className="emoji-picker-search">
|
|
<input placeholder="Search emoji…" autoFocus value={q} onChange={e => setQ(e.target.value)} />
|
|
</div>
|
|
<div className="emoji-picker-tabs">
|
|
{cats.map(c => (
|
|
<div key={c.key} className={`emoji-tab ${tab === c.key ? 'active' : ''}`} onClick={() => setTab(c.key)} title={c.title}>
|
|
{c.icon}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="emoji-grid-wrap">
|
|
{filtered ? (
|
|
<>
|
|
<div className="emoji-category-title">Results</div>
|
|
<div className="emoji-grid">
|
|
{filtered.map((e, i) => <div key={i} className="emoji-cell" onMouseEnter={() => setHover({ emoji: e, name: 'emoji' })} onClick={() => { onPick(e); onClose(); }}>{e}</div>)}
|
|
</div>
|
|
</>
|
|
) : cats.map(c => (
|
|
<div key={c.key} id={`cat-${c.key}`}>
|
|
<div className="emoji-category-title">{c.title}</div>
|
|
<div className="emoji-grid">
|
|
{c.emojis.map((e, i) => (
|
|
<div key={i} className="emoji-cell"
|
|
onMouseEnter={() => setHover({ emoji: e, name: c.title.toLowerCase() })}
|
|
onClick={() => { onPick(e); onClose(); }}>
|
|
{e}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="emoji-picker-preview">
|
|
{hover ? (
|
|
<>
|
|
<span className="big">{hover.emoji}</span>
|
|
<div>
|
|
<div className="name">{hover.name}</div>
|
|
<div className="shortcode">:{hover.name.replace(/\s/g, '_')}:</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="shortcode" style={{fontSize: 12}}>pick an emoji · 😊</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const MessageActionsMenu = ({ anchor, onClose, onAction }) => {
|
|
const ref = React.useRef(null);
|
|
React.useEffect(() => {
|
|
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
|
|
setTimeout(() => document.addEventListener('mousedown', h), 50);
|
|
return () => document.removeEventListener('mousedown', h);
|
|
}, []);
|
|
const style = anchor ? { top: anchor.top, left: Math.min(anchor.right, window.innerWidth - 230) } : {};
|
|
return (
|
|
<div className="context-menu" ref={ref} style={style}>
|
|
<div className="context-item" onClick={() => { onAction('reply'); onClose(); }}><Icon name="reply" size={14} /> Reply <span className="shortcut">R</span></div>
|
|
<div className="context-item" onClick={() => { onAction('thread'); onClose(); }}><Icon name="inbox" size={14} /> Reply in thread</div>
|
|
<div className="context-item" onClick={() => { onAction('edit'); onClose(); }}><Icon name="edit" size={14} /> Edit <span className="shortcut">E</span></div>
|
|
<div className="context-divider" />
|
|
<div className="context-item" onClick={() => { onAction('pin'); onClose(); }}><Icon name="pin" size={14} /> Pin to room</div>
|
|
<div className="context-item" onClick={() => { onAction('bookmark'); onClose(); }}><Icon name="bookmark" size={14} /> Save for later</div>
|
|
<div className="context-item" onClick={() => { onAction('copy'); onClose(); }}><Icon name="copy" size={14} /> Copy text <span className="shortcut">⌘C</span></div>
|
|
<div className="context-item" onClick={() => { onAction('link'); onClose(); }}><Icon name="link" size={14} /> Copy message link</div>
|
|
<div className="context-divider" />
|
|
<div className="context-item danger" onClick={() => { onAction('delete'); onClose(); }}><Icon name="trash" size={14} /> Delete message <span className="shortcut">⌫</span></div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ProfilePopover = ({ anchor, profile, onClose }) => {
|
|
const ref = React.useRef(null);
|
|
React.useEffect(() => {
|
|
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
|
|
setTimeout(() => document.addEventListener('mousedown', h), 50);
|
|
return () => document.removeEventListener('mousedown', h);
|
|
}, []);
|
|
const style = anchor
|
|
? { top: Math.min(anchor.top, window.innerHeight - 440), left: Math.min(anchor.right + 10, window.innerWidth - 340) }
|
|
: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
|
|
const p = profile || { name: 'juno', handle: '@juno:pyramid.chat', avatar: 'J', color: '#06b6d4', status: 'online', about: 'risograph + zines. teaching at the atelier tuesdays.', roles: [{ name: 'founder', color: '#f59e0b' }, { name: 'print-nerd', color: '#06b6d4' }] };
|
|
return (
|
|
<div className="profile-popover" ref={ref} style={style}>
|
|
<div className="profile-banner" />
|
|
<div className="profile-content">
|
|
<div className="profile-avatar" style={{ background: `linear-gradient(135deg, ${p.color}, ${p.color}cc)` }}>
|
|
{p.avatar}
|
|
<span className={`presence-dot ${p.status}`} style={{ width: 16, height: 16, borderWidth: 3 }} />
|
|
</div>
|
|
<div className="profile-name">{p.name}</div>
|
|
<div className="profile-handle">{p.handle}</div>
|
|
<div className="profile-actions">
|
|
<button className="profile-btn primary"><Icon name="send" size={13} /> Message</button>
|
|
<button className="profile-btn"><Icon name="voice" size={13} /> Call</button>
|
|
<button className="profile-btn" style={{ flex: 'none', padding: '7px 10px' }}><Icon name="dotsV" size={14} /></button>
|
|
</div>
|
|
<div className="profile-section">
|
|
<div className="profile-section-title">About</div>
|
|
<div className="profile-about">{p.about}</div>
|
|
</div>
|
|
<div className="profile-section">
|
|
<div className="profile-section-title">Roles</div>
|
|
<div className="profile-roles">
|
|
{(p.roles || []).map((r, i) => (
|
|
<span key={i} className="profile-role"><span className="dot" style={{ background: r.color }} /> {r.name}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="profile-section">
|
|
<div className="profile-section-title">Member since</div>
|
|
<div className="profile-about">March 2024 · via pyramid.chat</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SettingsModal = ({ onClose, theme, onTheme, accentPresets, accent, onAccent, density, onDensity, motion, onMotion, radius, onRadius }) => {
|
|
const [section, setSection] = React.useState('appearance');
|
|
return (
|
|
<div className="overlay" onClick={onClose}>
|
|
<div className="modal settings-modal" onClick={e => e.stopPropagation()}>
|
|
<nav className="settings-nav">
|
|
<div className="settings-nav-section">Account</div>
|
|
<div className={`settings-nav-item ${section === 'profile' ? 'active' : ''}`} onClick={() => setSection('profile')}><Icon name="users" size={14}/> My profile</div>
|
|
<div className={`settings-nav-item ${section === 'notifications' ? 'active' : ''}`} onClick={() => setSection('notifications')}><Icon name="bell" size={14}/> Notifications</div>
|
|
<div className="settings-nav-section" style={{marginTop: 14}}>App</div>
|
|
<div className={`settings-nav-item ${section === 'appearance' ? 'active' : ''}`} onClick={() => setSection('appearance')}><Icon name="palette" size={14}/> Appearance</div>
|
|
<div className={`settings-nav-item ${section === 'voice' ? 'active' : ''}`} onClick={() => setSection('voice')}><Icon name="voice" size={14}/> Voice & video</div>
|
|
<div className={`settings-nav-item ${section === 'keys' ? 'active' : ''}`} onClick={() => setSection('keys')}><Icon name="settings" size={14}/> Keybinds</div>
|
|
<div className="settings-nav-section" style={{marginTop: 14}}>Privacy</div>
|
|
<div className={`settings-nav-item ${section === 'privacy' ? 'active' : ''}`} onClick={() => setSection('privacy')}><Icon name="lock" size={14}/> Privacy & safety</div>
|
|
<div className={`settings-nav-item ${section === 'sessions' ? 'active' : ''}`} onClick={() => setSection('sessions')}><Icon name="globe" size={14}/> Sessions</div>
|
|
</nav>
|
|
<div className="settings-content">
|
|
<button className="modal-close" onClick={onClose}><Icon name="close" size={14} /></button>
|
|
{section === 'appearance' && (
|
|
<>
|
|
<h2>Appearance</h2>
|
|
<div className="settings-subtitle">Make Pyramid feel the way you want. Changes apply immediately.</div>
|
|
|
|
<div className="settings-group">
|
|
<div className="settings-group-title">Theme</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Mode</div>
|
|
<div className="settings-row-desc">Switch between dark and light</div>
|
|
</div>
|
|
<div className="seg" style={{ width: 200 }}>
|
|
<button className={theme === 'dark' ? 'active' : ''} onClick={() => onTheme('dark')}>Dark</button>
|
|
<button className={theme === 'light' ? 'active' : ''} onClick={() => onTheme('light')}>Light</button>
|
|
</div>
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Accent color</div>
|
|
<div className="settings-row-desc">Applies to highlights, buttons, active states</div>
|
|
</div>
|
|
<div className="tweak-swatches">
|
|
{accentPresets.map(p => (
|
|
<div key={p.name} className={`tweak-swatch ${accent === p.h ? 'active' : ''}`}
|
|
style={{ background: `hsl(${p.h} ${p.s}% ${p.l}%)` }}
|
|
title={p.name}
|
|
onClick={() => onAccent(p)} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="settings-group">
|
|
<div className="settings-group-title">Density & shape</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Density</div>
|
|
<div className="settings-row-desc">Cozy = more breathing room · compact = more on screen</div>
|
|
</div>
|
|
<div className="seg" style={{ width: 220 }}>
|
|
<button className={density === 0.85 ? 'active' : ''} onClick={() => onDensity(0.85)}>Compact</button>
|
|
<button className={density === 1 ? 'active' : ''} onClick={() => onDensity(1)}>Default</button>
|
|
<button className={density === 1.2 ? 'active' : ''} onClick={() => onDensity(1.2)}>Cozy</button>
|
|
</div>
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Corner roundness</div>
|
|
<div className="settings-row-desc">{radius}px</div>
|
|
</div>
|
|
<input type="range" min="4" max="20" value={radius} onChange={e => onRadius(+e.target.value)} style={{ width: 180, accentColor: 'var(--accent)' }} />
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Motion intensity</div>
|
|
<div className="settings-row-desc">How bouncy transitions feel ({Math.round(motion*100)}%)</div>
|
|
</div>
|
|
<input type="range" min="0" max="100" value={motion*100} onChange={e => onMotion(+e.target.value/100)} style={{ width: 180, accentColor: 'var(--accent)' }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="settings-group">
|
|
<div className="settings-group-title">Message display</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Show avatars on every message</div>
|
|
<div className="settings-row-desc">Off groups consecutive messages from the same person</div>
|
|
</div>
|
|
<div className="toggle" />
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Animated emoji & stickers</div>
|
|
<div className="settings-row-desc">Play animations on hover</div>
|
|
</div>
|
|
<div className="toggle on" />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
{section === 'profile' && (
|
|
<>
|
|
<h2>My profile</h2>
|
|
<div className="settings-subtitle">How you appear across pyramid.</div>
|
|
<div className="account-card">
|
|
<div className="avatar">M<span className="presence-dot online" style={{width:16,height:16,borderWidth:3}}/></div>
|
|
<div>
|
|
<div className="account-name">mira</div>
|
|
<div className="account-handle">@mira:pyramid.chat</div>
|
|
<div className="profile-about" style={{marginTop: 4}}>in the zone 🎨</div>
|
|
</div>
|
|
<button className="profile-btn" style={{ flex: 'none', marginLeft: 'auto' }}><Icon name="edit" size={13}/> Edit</button>
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Display name</div>
|
|
<div className="settings-row-desc">mira</div>
|
|
</div>
|
|
<button className="profile-btn" style={{ flex: 'none' }}>Change</button>
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Avatar</div>
|
|
<div className="settings-row-desc">Upload an image or pick a gradient</div>
|
|
</div>
|
|
<button className="profile-btn" style={{ flex: 'none' }}>Upload</button>
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Status message</div>
|
|
<div className="settings-row-desc">Shown next to your name to friends</div>
|
|
</div>
|
|
<button className="profile-btn" style={{ flex: 'none' }}>Edit</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{section === 'notifications' && (
|
|
<>
|
|
<h2>Notifications</h2>
|
|
<div className="settings-subtitle">Tune what pings you and when.</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Enable desktop notifications</div>
|
|
<div className="settings-row-desc">Lets pyramid show OS-level alerts</div>
|
|
</div>
|
|
<div className="toggle on" />
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Mentions only</div>
|
|
<div className="settings-row-desc">Suppress everything except @-mentions and DMs</div>
|
|
</div>
|
|
<div className="toggle" />
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Notification sound</div>
|
|
<div className="settings-row-desc">A soft little chime</div>
|
|
</div>
|
|
<select className="settings-select">
|
|
<option>Gentle</option><option>Classic</option><option>Silent</option>
|
|
</select>
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Do not disturb</div>
|
|
<div className="settings-row-desc">Mute notifications on a schedule</div>
|
|
</div>
|
|
<div className="toggle" />
|
|
</div>
|
|
</>
|
|
)}
|
|
{section === 'voice' && (
|
|
<>
|
|
<h2>Voice & video</h2>
|
|
<div className="settings-subtitle">Mic, camera, and call preferences.</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Input device</div>
|
|
<div className="settings-row-desc">Your mic</div>
|
|
</div>
|
|
<select className="settings-select"><option>Built-in microphone</option><option>AirPods Pro</option></select>
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Noise suppression</div>
|
|
<div className="settings-row-desc">Smart mode based on what you're saying</div>
|
|
</div>
|
|
<div className="toggle on" />
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Echo cancellation</div>
|
|
<div className="settings-row-desc">Strips feedback from speaker bleed</div>
|
|
</div>
|
|
<div className="toggle on" />
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Push to talk</div>
|
|
<div className="settings-row-desc">Hold a key to transmit</div>
|
|
</div>
|
|
<div className="toggle" />
|
|
</div>
|
|
</>
|
|
)}
|
|
{section === 'privacy' && (
|
|
<>
|
|
<h2>Privacy & safety</h2>
|
|
<div className="settings-subtitle">Pyramid is end-to-end encrypted by default.</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Who can DM me</div>
|
|
<div className="settings-row-desc">Control who starts a direct message with you</div>
|
|
</div>
|
|
<select className="settings-select"><option>Everyone</option><option>Friends & shared spaces</option><option>Only friends</option></select>
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Read receipts</div>
|
|
<div className="settings-row-desc">Let others see when you've read their messages</div>
|
|
</div>
|
|
<div className="toggle on" />
|
|
</div>
|
|
<div className="settings-row">
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">Sent typing indicators</div>
|
|
<div className="settings-row-desc">Others see when you're composing</div>
|
|
</div>
|
|
<div className="toggle on" />
|
|
</div>
|
|
</>
|
|
)}
|
|
{section === 'keys' && (
|
|
<>
|
|
<h2>Keybinds</h2>
|
|
<div className="settings-subtitle">Quick movements.</div>
|
|
{[
|
|
['Quick switcher', '⌘K'],
|
|
['Mark room as read', '⇧⎋'],
|
|
['Jump to unread', '⌘J'],
|
|
['Toggle left rail', '⌘B'],
|
|
['Toggle members', '⌘⇧U'],
|
|
['Emoji picker', '⌘E'],
|
|
].map(([label, key]) => (
|
|
<div className="settings-row" key={label}>
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">{label}</div>
|
|
</div>
|
|
<kbd style={{padding: '3px 8px', background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 6, fontFamily: 'var(--font-mono)', fontSize: 12}}>{key}</kbd>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
{section === 'sessions' && (
|
|
<>
|
|
<h2>Sessions</h2>
|
|
<div className="settings-subtitle">Devices where you're currently signed in.</div>
|
|
{[
|
|
{ dev: 'MacBook Pro · Safari', loc: 'Berlin, DE · now', current: true },
|
|
{ dev: 'iPhone 15 · Pyramid iOS', loc: 'Berlin, DE · 2h ago' },
|
|
{ dev: 'Chrome · ThinkPad', loc: 'Lisbon, PT · 3d ago' },
|
|
].map((s, i) => (
|
|
<div className="settings-row" key={i}>
|
|
<div className="settings-row-label">
|
|
<div className="settings-row-title">{s.dev}{s.current && <span className="badge" style={{marginLeft:8, background:'var(--accent-soft)', color:'var(--accent)'}}>this device</span>}</div>
|
|
<div className="settings-row-desc">{s.loc}</div>
|
|
</div>
|
|
{!s.current && <button className="profile-btn" style={{flex:'none', color:'var(--danger)'}}>Revoke</button>}
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ===== Search ===== */
|
|
const SearchModal = ({ onClose, onJump }) => {
|
|
const [q, setQ] = React.useState('');
|
|
const [filter, setFilter] = React.useState('all');
|
|
|
|
const allResults = [
|
|
{ kind: 'message', room: '#general', author: 'juno', avatar: 'J', color: '#06b6d4', snippet: 'turned out way better than expected — the mis-registration is doing a LOT of heavy lifting', time: 'today · 2:14 PM' },
|
|
{ kind: 'message', room: '#resources', author: 'juno', avatar: 'J', color: '#06b6d4', snippet: 'risograph studio on baker street — 4hr blocks for $30', time: 'yesterday' },
|
|
{ kind: 'room', name: 'critique-circle', snippet: 'Atelier · 142 members', avatar: '#', color: '#f59e0b' },
|
|
{ kind: 'person', name: 'juno', snippet: '@juno:pyramid.chat · online', avatar: 'J', color: '#06b6d4' },
|
|
{ kind: 'message', room: 'juno', author: 'juno', avatar: 'J', color: '#06b6d4', snippet: 'bringing pastries — riso prints later', time: '3d ago' },
|
|
{ kind: 'file', name: 'zine-layout-final.pdf', snippet: 'Shared by juno · Atelier #wip · 2MB', avatar: '📄' },
|
|
];
|
|
const filtered = allResults.filter(r => filter === 'all' || r.kind === filter);
|
|
|
|
return (
|
|
<div className="overlay" onClick={onClose}>
|
|
<div className="modal search-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="search-input-row">
|
|
<Icon name="search" size={18} />
|
|
<input autoFocus placeholder="Search messages, rooms, people, files…" value={q} onChange={e => setQ(e.target.value)} />
|
|
<span className="esc">esc</span>
|
|
</div>
|
|
<div className="search-filters">
|
|
{[
|
|
{ k: 'all', label: 'All', icon: 'sparkle' },
|
|
{ k: 'message', label: 'Messages', icon: 'hash' },
|
|
{ k: 'person', label: 'People', icon: 'users' },
|
|
{ k: 'room', label: 'Rooms', icon: 'hash' },
|
|
{ k: 'file', label: 'Files', icon: 'paperclip' },
|
|
].map(f => (
|
|
<button key={f.k} className={`search-chip ${filter === f.k ? 'active' : ''}`} onClick={() => setFilter(f.k)}>
|
|
<Icon name={f.icon} size={11} /> {f.label}
|
|
</button>
|
|
))}
|
|
<button className="search-chip"><Icon name="filter" size={11} /> From: anyone</button>
|
|
<button className="search-chip"><Icon name="filter" size={11} /> In: any room</button>
|
|
<button className="search-chip"><Icon name="filter" size={11} /> Has: all</button>
|
|
</div>
|
|
<div className="search-results">
|
|
{filtered.map((r, i) => (
|
|
<div key={i} className={`search-result ${i === 0 ? 'selected' : ''}`} onClick={onClose}>
|
|
<div className="avatar" style={{background: r.color || 'var(--bg-3)'}}>{r.avatar}</div>
|
|
<div className="search-result-content">
|
|
<div className="search-result-top">
|
|
<span className="search-result-name">{r.name || r.author}</span>
|
|
<span className="search-result-meta">{r.room || r.kind}{r.time ? ` · ${r.time}` : ''}</span>
|
|
</div>
|
|
<div className="search-result-snippet" dangerouslySetInnerHTML={{
|
|
__html: q
|
|
? r.snippet.replace(new RegExp(`(${q})`, 'ig'), '<mark>$1</mark>')
|
|
: r.snippet
|
|
}} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<div style={{padding: 40, textAlign: 'center', color: 'var(--fg-dim)'}}>nothing here — try a different filter?</div>
|
|
)}
|
|
</div>
|
|
<div className="search-footer">
|
|
<span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
|
|
<span><kbd>↵</kbd> select</span>
|
|
<span><kbd>⌘</kbd><kbd>↵</kbd> open in new pane</span>
|
|
<span style={{marginLeft:'auto'}}>{filtered.length} results</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ===== Voice Channel ===== */
|
|
const VoiceChannel = ({ call, onLeave, onToggleRail, onOpenProfile, onToggleMic, onToggleDeaf, onToggleCam, onToggleScreen }) => {
|
|
const [fullscreen, setFullscreen] = React.useState(false);
|
|
const [qualityOpen, setQualityOpen] = React.useState(false);
|
|
const [quality, setQuality] = React.useState('1080p60');
|
|
|
|
React.useEffect(() => {
|
|
if (!qualityOpen) return;
|
|
const h = (e) => { if (!e.target.closest('.stream-quality-menu') && !e.target.closest('[data-quality-trigger]')) setQualityOpen(false); };
|
|
setTimeout(() => document.addEventListener('mousedown', h), 50);
|
|
return () => document.removeEventListener('mousedown', h);
|
|
}, [qualityOpen]);
|
|
|
|
const participants = call?.participants || [];
|
|
const streaming = call?.streaming;
|
|
const streamer = call?.streamer || 'mira';
|
|
|
|
if (!call?.active) {
|
|
return (
|
|
<main className="chat">
|
|
<div className="chat-header">
|
|
<button className="icon-btn" onClick={onToggleRail}><Icon name="sidebarLeft" size={16} /></button>
|
|
<div className="chat-header-title"><Icon name="voice" size={16} /><span>No active call</span></div>
|
|
</div>
|
|
<div className="voice-hero" style={{alignItems: 'center', justifyContent: 'center'}}>
|
|
<div style={{color: 'var(--fg-muted)', fontSize: 14, textAlign: 'center'}}>
|
|
<div style={{fontSize: 32, marginBottom: 12, opacity: 0.4}}>𐃏</div>
|
|
<div>Join a voice room to start a call</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main className="chat">
|
|
<div className="chat-header">
|
|
<button className="icon-btn" onClick={onToggleRail}><Icon name="sidebarLeft" size={16} /></button>
|
|
<div className="chat-header-title">
|
|
<Icon name="voice" size={16} />
|
|
<span>{call?.roomName || 'The Lounge'}</span>
|
|
<span className="voice-live-pill" style={{marginLeft: 8}}>
|
|
<span className="live-dot" /> Live · {participants.length}
|
|
</span>
|
|
</div>
|
|
<div className="chat-header-actions">
|
|
<button className="icon-btn"><Icon name="users" size={16} /></button>
|
|
<button className="icon-btn"><Icon name="settings" size={16} /></button>
|
|
</div>
|
|
</div>
|
|
<div className="voice-hero">
|
|
<div className="voice-layout">
|
|
{streaming ? (
|
|
<>
|
|
<div className={`stream-hero ${fullscreen ? 'fullscreen' : ''}`}>
|
|
<div className="stream-hero-content">
|
|
<div className="stream-hero-center">
|
|
<div className="big-icon"><Icon name="screen" size={28} /></div>
|
|
<div className="big-label">{streamer}'s screen · {quality.toUpperCase()}</div>
|
|
</div>
|
|
</div>
|
|
<div className="stream-hero-topbar">
|
|
<span className="stream-live-pill"><span className="dot" /> Live</span>
|
|
<div className="stream-hero-title">{streamer}'s stream</div>
|
|
<div style={{flex: 1}} />
|
|
<div className="stream-hero-viewers"><Icon name="users" size={11} /> {participants.length} watching</div>
|
|
<div className="stream-hero-viewers"><Icon name="sparkle" size={11} /> 2.3 mbps</div>
|
|
</div>
|
|
<div className="stream-hero-bottombar">
|
|
<button className="stream-hero-btn primary" onClick={onToggleScreen} title="Stop stream">
|
|
<Icon name="screen" size={16} />
|
|
</button>
|
|
<button className="stream-hero-btn" data-quality-trigger onClick={() => setQualityOpen(v => !v)} title="Quality">
|
|
<Icon name="settings" size={16} />
|
|
</button>
|
|
<div className="stream-hero-spacer" />
|
|
<button className="stream-hero-btn" title="Picture-in-picture">
|
|
<Icon name="sidebarLeft" size={16} />
|
|
</button>
|
|
<button className="stream-hero-btn" onClick={() => setFullscreen(f => !f)} title={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
|
|
<Icon name={fullscreen ? 'close' : 'globe'} size={16} />
|
|
</button>
|
|
</div>
|
|
{qualityOpen && (
|
|
<div className="stream-quality-menu">
|
|
<div className="stream-quality-title">Video quality</div>
|
|
{[
|
|
{ k: 'source', label: 'Source', hint: 'as-is' },
|
|
{ k: '1080p60', label: '1080p', hint: '60 fps' },
|
|
{ k: '1080p30', label: '1080p', hint: '30 fps' },
|
|
{ k: '720p60', label: '720p', hint: '60 fps' },
|
|
{ k: '480p', label: '480p', hint: 'data-saver' },
|
|
].map(q => (
|
|
<div key={q.k} className="stream-quality-item" onClick={() => { setQuality(q.k); setQualityOpen(false); }}>
|
|
<span className="check-col">{quality === q.k && <Icon name="check" size={12} />}</span>
|
|
<span className="label">{q.label}</span>
|
|
<span className="hint">{q.hint}</span>
|
|
</div>
|
|
))}
|
|
<div className="stream-quality-divider" />
|
|
<div className="stream-quality-title">Audio</div>
|
|
<div className="stream-quality-item"><span className="check-col"><Icon name="check" size={12} /></span><span className="label">Stereo · 256 kbps</span></div>
|
|
<div className="stream-quality-item"><span className="check-col"></span><span className="label">Mono · 96 kbps</span></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="voice-participants-row">
|
|
{participants.map((p, i) => (
|
|
<div key={i} className={`voice-tile small ${p.speaking ? 'speaking' : ''}`}>
|
|
<div className="voice-tile-avatar" style={{ background: `linear-gradient(135deg, ${p.color}, ${p.color}99)` }} onClick={() => onOpenProfile?.(p.name)}>{p.avatar}</div>
|
|
<div className="voice-tile-name">
|
|
{p.speaking && <span className="voice-wave"><span /><span /><span /><span /></span>}
|
|
{p.muted && <Icon name="micOff" size={10} />}
|
|
<span>{p.name}</span>
|
|
</div>
|
|
<div className="voice-status-icons">
|
|
{p.muted && <div className="voice-status-icon muted"><Icon name="micOff" size={10} /></div>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="voice-grid">
|
|
{participants.map((p, i) => (
|
|
<div key={i} className={`voice-tile ${p.speaking ? 'speaking' : ''}`}>
|
|
<div className="voice-tile-avatar" style={{ background: `linear-gradient(135deg, ${p.color}, ${p.color}99)` }} onClick={() => onOpenProfile?.(p.name)}>{p.avatar}</div>
|
|
<div className="voice-tile-name">
|
|
{p.speaking && <span className="voice-wave"><span /><span /><span /><span /></span>}
|
|
{p.muted && <Icon name="micOff" size={11} />}
|
|
<span>{p.name}</span>
|
|
</div>
|
|
<div className="voice-status-icons">
|
|
{p.muted && <div className="voice-status-icon muted"><Icon name="micOff" size={12} /></div>}
|
|
{!p.cam && <div className="voice-status-icon"><Icon name="videoOff" size={12} /></div>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="voice-controls">
|
|
<button className={`voice-ctrl-btn ${call?.mic ? '' : 'danger'}`} onClick={onToggleMic}>
|
|
<Icon name={call?.mic ? 'mic' : 'micOff'} size={20} />
|
|
</button>
|
|
<button className={`voice-ctrl-btn ${call?.cam ? 'on' : ''}`} onClick={onToggleCam}>
|
|
<Icon name={call?.cam ? 'video' : 'videoOff'} size={20} />
|
|
</button>
|
|
<button className={`voice-ctrl-btn ${streaming ? 'on' : ''}`} onClick={onToggleScreen} title={streaming ? 'Stop stream' : 'Share screen'}>
|
|
<Icon name="screen" size={20} />
|
|
</button>
|
|
<button className={`voice-ctrl-btn ${call?.deaf ? 'danger' : ''}`} onClick={onToggleDeaf}>
|
|
<Icon name="headphones" size={20} />
|
|
</button>
|
|
<div style={{ width: 1, height: 32, background: 'var(--border)' }} />
|
|
<button className="voice-ctrl-btn danger" onClick={onLeave}>
|
|
<Icon name="phoneOff" size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
};
|
|
|
|
/* ===== Login ===== */
|
|
const LoginScreen = ({ onLogin }) => {
|
|
const [handle, setHandle] = React.useState('mira');
|
|
const [pw, setPw] = React.useState('••••••••••');
|
|
const [server, setServer] = React.useState('pyramid.chat');
|
|
const pyramids = Array.from({ length: 6 }, (_, i) => i);
|
|
return (
|
|
<div className="login-shell">
|
|
{pyramids.map(i => {
|
|
const s = 40 + Math.random() * 80;
|
|
return (
|
|
<div key={i} className="login-float" style={{
|
|
left: `${10 + i * 15}%`,
|
|
top: `${10 + (i % 3) * 30}%`,
|
|
'--rot': `${(i * 23) % 360}deg`,
|
|
animationDelay: `${i * 0.7}s`,
|
|
}}>
|
|
<PyramidMark size={s} />
|
|
</div>
|
|
);
|
|
})}
|
|
<form className="login-card" onSubmit={e => { e.preventDefault(); onLogin(); }}>
|
|
<div className="login-brand">
|
|
<div className="login-brand-mark"><PyramidMark size={56} /></div>
|
|
<div>
|
|
<div className="login-brand-name" style={{textAlign:'center'}}>pyramid</div>
|
|
<div className="login-brand-tag">the chat that stacks. encrypted, decentralized, yours.</div>
|
|
</div>
|
|
</div>
|
|
<div className="login-field">
|
|
<div className="login-field-label">Handle</div>
|
|
<input value={handle} onChange={e => setHandle(e.target.value)} placeholder="you" />
|
|
</div>
|
|
<div className="login-field">
|
|
<div className="login-field-label">Password</div>
|
|
<input type="password" value={pw} onChange={e => setPw(e.target.value)} />
|
|
</div>
|
|
<div className="login-field">
|
|
<div className="login-field-label">Homeserver</div>
|
|
<input value={server} onChange={e => setServer(e.target.value)} />
|
|
</div>
|
|
<button type="submit" className="login-submit">
|
|
Enter pyramid <Icon name="arrowUp" size={14} style={{transform:'rotate(90deg)'}} />
|
|
</button>
|
|
<div className="login-divider">or</div>
|
|
<div className="login-alt-row">
|
|
<button type="button" className="login-alt-btn"><Icon name="link" size={13} /> Magic link</button>
|
|
<button type="button" className="login-alt-btn"><Icon name="lock" size={13} /> Security key</button>
|
|
</div>
|
|
<div className="login-foot">
|
|
new here? <a onClick={onLogin}>make an account →</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
Object.assign(window, { EmojiPicker, MessageActionsMenu, ProfilePopover, SettingsModal, SearchModal, VoiceChannel, LoginScreen });
|