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:
+273
@@ -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 });
|
||||
Reference in New Issue
Block a user