wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)

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
This commit is contained in:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+273
View File
@@ -0,0 +1,273 @@
/* Chat view — messages, composer, hover actions */
const renderText = (text, onMention) => {
if (!text) return null;
const parts = [];
const regex = /(@\w+|#[\w-]+|https?:\/\/\S+|`[^`]+`)/g;
let last = 0;
text.replace(regex, (m, _, idx) => {
if (idx > last) parts.push(text.slice(last, idx));
if (m.startsWith('@')) parts.push(<span key={idx} className="mention" onClick={() => onMention?.(m)}>{m}</span>);
else if (m.startsWith('#')) parts.push(<a key={idx}>{m}</a>);
else if (m.startsWith('http')) parts.push(<a key={idx}>{m}</a>);
else if (m.startsWith('`')) parts.push(<code key={idx}>{m.slice(1, -1)}</code>);
last = idx + m.length;
return m;
});
if (last < text.length) parts.push(text.slice(last));
return parts;
};
const Message = ({ msg, onOpenProfile, onReact, onOpenActions, onOpenEmoji }) => {
const hasAvatar = !msg.continuation;
return (
<div className={`msg-group ${hasAvatar ? 'has-avatar' : ''}`} data-msg-id={msg.id}>
<div className="msg-avatar-col">
{hasAvatar ? (
<div className="msg-avatar avatar" style={{ background: msg.color }} onClick={(e) => onOpenProfile?.(msg.author, e)}>
{msg.avatar}
</div>
) : (
<div className="msg-timestamp-col">{msg.time}</div>
)}
</div>
<div className="msg-body">
{hasAvatar && (
<div className="msg-header">
<span className="msg-author" onClick={(e) => onOpenProfile?.(msg.author, e)}>{msg.author}</span>
<span className="msg-time">{msg.time}</span>
</div>
)}
{msg.text && <div className="msg-text">{renderText(msg.text)}</div>}
{msg.image && (
<div className="msg-image" style={{
background: 'repeating-linear-gradient(45deg, var(--bg-3), var(--bg-3) 8px, var(--bg-2) 8px, var(--bg-2) 16px)'
}}>
<span className="msg-image-label">{msg.image}</span>
</div>
)}
{msg.reactions && msg.reactions.length > 0 && (
<div className="reactions">
{msg.reactions.map((r, i) => (
<button key={i} className={`reaction ${r.mine ? 'mine' : ''}`} onClick={() => onReact?.(msg.id, r.emoji)}>
<span className="emoji">{r.emoji}</span>
<span className="count">{r.count}</span>
</button>
))}
<button className="reaction-add" onClick={(e) => onOpenEmoji?.(msg.id, e)}>
<Icon name="smile" size={12} />
</button>
</div>
)}
</div>
<div className="msg-actions">
<button className="msg-action emoji-btn" title="Reaction" onClick={(e) => onOpenEmoji?.(msg.id, e)}>
<Icon name="smile" size={16} />
</button>
<button className="msg-action" title="Reply in thread"><Icon name="reply" size={16} /></button>
<button className="msg-action" title="Pin"><Icon name="pin" size={16} /></button>
<button className="msg-action" title="More" onClick={(e) => onOpenActions?.(msg.id, e)}>
<Icon name="dots" size={16} />
</button>
</div>
</div>
);
};
const ChatHeader = ({ room, roomsData, onToggleMembers, membersOpen, onOpenSearch, onToggleRail, railCollapsed }) => {
const roomDef = findRoom(roomsData, room);
const isDM = roomDef?.type === 'dm';
const title = roomDef?.name || room;
const topic = isDM
? `direct message with ${roomDef.name}`
: 'hi-fi corner for paintings, zines, riso, and whatever else';
return (
<div className="chat-header">
<button className="icon-btn" onClick={onToggleRail} title={railCollapsed ? 'Show spaces' : 'Hide spaces'}>
<Icon name="sidebarLeft" size={16} />
</button>
<div className="chat-header-title">
{isDM ? (
<div className="avatar" style={{ width: 26, height: 26, borderRadius: 8, background: roomDef.color, fontSize: 11, position: 'relative' }}>
{roomDef.avatar}
<span className={`presence-dot ${roomDef.presence}`} style={{ width: 8, height: 8, borderWidth: 2 }} />
</div>
) : (
<span style={{color: 'var(--fg-dim)'}}>{roomIcon(roomDef?.type || 'text')}</span>
)}
<span>{title}</span>
</div>
{!isDM && <div className="chat-header-topic">{topic}</div>}
{isDM && <div style={{flex: 1}} />}
<div className="chat-header-actions">
{isDM && (
<>
<button className="icon-btn call-btn voice" title="Start voice call" onClick={() => window.__startDMCall?.(roomDef, 'voice')}>
<Icon name="voice" size={16} />
</button>
<button className="icon-btn call-btn video" title="Start video call" onClick={() => window.__startDMCall?.(roomDef, 'video')}>
<Icon name="video" size={16} />
</button>
<div style={{width: 1, height: 20, background: 'var(--border)', margin: '0 4px'}} />
</>
)}
<button className="icon-btn" title="Threads"><Icon name="inbox" size={16} /></button>
<button className="icon-btn" title="Pinned"><Icon name="pin" size={16} /></button>
<button className="icon-btn" title="Search" onClick={onOpenSearch}><Icon name="search" size={16} /></button>
<button className={`icon-btn ${membersOpen ? 'active' : ''}`} onClick={onToggleMembers} title="Members"
style={membersOpen ? { background: 'var(--bg-hover)', color: 'var(--fg)'} : {}}>
<Icon name="users" size={16} />
</button>
</div>
</div>
);
};
const findRoom = (roomsData, id) => {
if (!roomsData) return null;
for (const section of roomsData.sections) {
const r = section.rooms.find(r => r.id === id);
if (r) return r;
}
return null;
};
const Composer = ({ room, onSend, onOpenEmoji }) => {
const [text, setText] = React.useState('');
const ref = React.useRef(null);
const submit = () => {
if (text.trim()) {
onSend(text);
setText('');
}
};
const handleKey = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const roomName = room?.name || room;
const placeholder = typeof roomName === 'string' && roomName.startsWith('dm-')
? `Message…`
: `Message #${typeof roomName === 'string' ? roomName.replace('dm-', '') : 'room'}`;
return (
<div className="composer-wrap">
<div className="composer">
<div className="composer-input-row">
<button className="icon-btn composer-plus" title="Attach"><Icon name="plus" size={16} /></button>
<textarea
ref={ref}
className="composer-input"
placeholder={placeholder}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKey}
rows={1}
/>
<div className="composer-inline-tools">
<button className="icon-btn" title="GIF"><Icon name="gif" size={14} /></button>
<button className="icon-btn" title="Emoji" onClick={(e) => onOpenEmoji('composer', e)}>
<Icon name="smile" size={14} />
</button>
<button className="icon-btn" title="Mention"><Icon name="at" size={13} /></button>
</div>
<button className={`composer-send ${text.trim() ? 'active' : ''}`} onClick={submit} title="Send">
<Icon name="send" size={14} />
</button>
</div>
</div>
</div>
);
};
const MembersPanel = ({ room, members }) => {
const data = members || { online: [], offline: [] };
return (
<aside className="members">
<div className="members-header">
<Icon name="users" size={16} />
<span>Members</span>
<span className="count">{data.online.length + data.offline.length}</span>
</div>
<div className="members-list">
{data.online.length > 0 && (
<>
<div className="member-section-head">Online {data.online.length}</div>
{data.online.map((m, i) => (
<div className="member-item" key={'o'+i}>
<div className="avatar" style={{ background: m.color }}>
{m.avatar}
<span className={`presence-dot ${m.status}`} />
</div>
<div className="member-info">
<div className="member-name">{m.name}{m.self ? ' (you)' : ''}</div>
{m.note && <div className="member-note">{m.note}</div>}
</div>
</div>
))}
</>
)}
{data.offline.length > 0 && (
<>
<div className="member-section-head" style={{marginTop: 14}}>Offline {data.offline.length}</div>
{data.offline.map((m, i) => (
<div className="member-item offline" key={'f'+i}>
<div className="avatar" style={{ background: m.color }}>{m.avatar}</div>
<div className="member-info">
<div className="member-name">{m.name}</div>
</div>
</div>
))}
</>
)}
</div>
</aside>
);
};
const DateDivider = ({ label }) => <div className="date-divider"><span>{label}</span></div>;
const ChatView = ({ room, roomsData, messages, members, onOpenProfile, onOpenActions, onOpenEmoji, onToggleMembers, membersOpen, onOpenSearch, onToggleRail, railCollapsed, onSend, onReact }) => {
const messagesRef = React.useRef(null);
React.useEffect(() => {
if (messagesRef.current) messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
}, [room, messages?.length]);
const roomDef = findRoom(roomsData, room);
return (
<main className="chat">
<ChatHeader
room={room}
roomsData={roomsData}
onToggleMembers={onToggleMembers}
membersOpen={membersOpen}
onOpenSearch={onOpenSearch}
onToggleRail={onToggleRail}
railCollapsed={railCollapsed}
/>
<div className="messages" ref={messagesRef}>
<DateDivider label="Today · April 19" />
{messages?.map(m => (
<Message
key={m.id}
msg={m}
onOpenProfile={onOpenProfile}
onReact={onReact}
onOpenActions={onOpenActions}
onOpenEmoji={onOpenEmoji}
/>
))}
</div>
<Composer room={roomDef || room} onSend={onSend} onOpenEmoji={onOpenEmoji} />
</main>
);
};
Object.assign(window, { ChatView, Message, Composer, ChatHeader, MembersPanel, DateDivider, findRoom, roomIcon });