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
274 lines
10 KiB
React
274 lines
10 KiB
React
/* 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 });
|