1507 lines
78 KiB
JavaScript
1507 lines
78 KiB
JavaScript
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
|
|
import ServerInviteCard from './ServerInviteCard.jsx';
|
|
import UserProfileModal from './UserProfileModal.jsx';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import remarkBreaks from 'remark-breaks';
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
|
|
const QUICK_EMOJIS = ['❤️', '😂', '💯', '🔥'];
|
|
const ALL_EMOJIS = ['😀','😂','🤣','😊','😍','🥰','😎','🤩','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','🤐','🤨','😐','😑','😶','😏','😒','🙄','😬','🤥','😌','😔','😪','🤤','😴','😷','🤒','🤕','🤢','🤮','🤧','🥵','🥶','🥴','😵','🤯','🤠','🥳','😎','🤓','🧐','😕','😟','🙁','☹️','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻','👽','👾','🤖','😺','😸','😹','😻','😼','😽','🙀','😿','😾','❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝','👍','👎','👊','✊','🤛','🤜','🤞','✌️','🤟','🤘','👌','🤏','👈','👉','👆','👇','☝️','✋','🤚','🖐','🖖','👋','🤙','💪','🦾','🖕','✍️','🙏','🦶','🦵','🦿','💄','💋','👄','🦷','👅','👂','🦻','👃','👣','👁','👀','🧠','🗣','👤','👥','💯','💢','💥','💫','💦','💨','🕳','💣','💬','👁️🗨️','🗨️','🗯️','💭','💤'];
|
|
|
|
const MarkdownComponents = {
|
|
code({node, inline, className, children, ...props}) {
|
|
const match = /language-(\w+)/.exec(className || '')
|
|
return !inline && match ? (
|
|
<SyntaxHighlighter
|
|
style={vscDarkPlus}
|
|
language={match[1]}
|
|
PreTag="div"
|
|
className="rounded-md !my-2 !bg-base border border-surface text-sm custom-scrollbar"
|
|
{...props}
|
|
>
|
|
{String(children).replace(/\n$/, '')}
|
|
</SyntaxHighlighter>
|
|
) : (
|
|
<code className="bg-base text-text px-1.5 py-0.5 rounded font-mono text-[13px] before:content-none after:content-none" {...props}>
|
|
{children}
|
|
</code>
|
|
)
|
|
},
|
|
a: ({node, href, children, ...props}) => {
|
|
if (href && href.startsWith('mention://')) {
|
|
return <span className="bg-[#5865F2]/30 !text-[#c9cdfb] hover:bg-[#5865F2] hover:!text-white px-1.5 py-0.5 rounded-md font-medium cursor-pointer transition-colors no-underline">{children}</span>;
|
|
}
|
|
if (href && href.startsWith('channel://')) {
|
|
return <span className="bg-[#5865F2]/30 !text-[#c9cdfb] hover:bg-[#5865F2] hover:!text-white px-1.5 py-0.5 rounded-md font-medium cursor-pointer transition-colors no-underline">{children}</span>;
|
|
}
|
|
return <a className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer" href={href} {...props}>{children}</a>;
|
|
},
|
|
p: ({node, children, ...props}) => <p className="mb-2 last:mb-0 whitespace-pre-wrap" {...props}>{children}</p>
|
|
};
|
|
|
|
const processMentionsAndChannels = (text) => {
|
|
if (!text) return '';
|
|
let processed = text.replace(/(^|\s)(@everyone|@[a-zA-Z0-9_.]+)/g, '$1[**$2**](mention://$2)');
|
|
processed = processed.replace(/(^|\s)(#[a-z0-9-]+)/g, '$1[**$2**](channel://$2)');
|
|
return processed;
|
|
};
|
|
|
|
const formatBytes = (bytes, decimals = 2) => {
|
|
if (!+bytes) return '0 Bytes';
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes =['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
};
|
|
|
|
const DECRYPTION_INITIAL_DELAY_MS = 400;
|
|
const DECRYPTION_SPEED_MS = 30;
|
|
const DECRYPTION_CHARS_PER_TICK = 1;
|
|
|
|
const DecryptedMessage = ({ msg, liveDecryption, animationTrigger, components }) => {
|
|
const [isAnimating, setIsAnimating] = useState(false);
|
|
const [revealed, setRevealed] = useState(0);
|
|
const [gibberish, setGibberish] = useState('');
|
|
const lastTrigger = useRef(animationTrigger);
|
|
|
|
useEffect(() => {
|
|
if (!liveDecryption || !msg.isEncrypted || !msg.text) {
|
|
setIsAnimating(false);
|
|
return;
|
|
}
|
|
|
|
const isNewTrigger = animationTrigger !== lastTrigger.current;
|
|
lastTrigger.current = animationTrigger;
|
|
|
|
const cacheKey = `animated_${msg.id}`;
|
|
if (!isNewTrigger && sessionStorage.getItem(cacheKey)) {
|
|
setIsAnimating(false);
|
|
return;
|
|
}
|
|
|
|
sessionStorage.setItem(cacheKey, 'true');
|
|
|
|
const text = msg.text;
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
|
|
let gib = '';
|
|
let seed = 0;
|
|
const cipher = msg.cipher || 'fallback';
|
|
for(let i=0; i<cipher.length; i++) seed += cipher.charCodeAt(i);
|
|
|
|
for(let i=0; i<text.length; i++) {
|
|
const rand = Math.abs(Math.sin(seed + i) * 10000);
|
|
gib += chars[Math.floor(rand * chars.length) % chars.length];
|
|
}
|
|
|
|
setGibberish(gib);
|
|
setRevealed(0);
|
|
setIsAnimating(true);
|
|
|
|
let curr = 0;
|
|
let interval;
|
|
|
|
const timeout = setTimeout(() => {
|
|
interval = setInterval(() => {
|
|
curr += DECRYPTION_CHARS_PER_TICK;
|
|
if (curr >= text.length) {
|
|
setRevealed(text.length);
|
|
setIsAnimating(false);
|
|
clearInterval(interval);
|
|
} else {
|
|
setRevealed(curr);
|
|
}
|
|
}, DECRYPTION_SPEED_MS);
|
|
}, DECRYPTION_INITIAL_DELAY_MS);
|
|
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
if (interval) clearInterval(interval);
|
|
};
|
|
}, [msg.text, msg.cipher, msg.isEncrypted, liveDecryption, msg.id, animationTrigger]);
|
|
|
|
if (!isAnimating) {
|
|
return (
|
|
<div className="prose prose-invert max-w-none text-[15px] prose-p:leading-relaxed prose-pre:p-0 prose-pre:bg-transparent prose-a:text-blue-400 hover:prose-a:text-blue-300 prose-img:rounded-md prose-img:max-h-96 prose-img:object-contain prose-headings:my-2 prose-h1:text-2xl prose-h1:font-bold prose-h2:text-xl prose-h2:font-bold prose-h3:text-lg prose-h3:font-bold prose-ul:my-1 prose-ol:my-1 prose-li:my-0 break-words">
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
|
components={components || MarkdownComponents}
|
|
urlTransform={(value) => value}
|
|
>
|
|
{processMentionsAndChannels(msg.text || '')}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="font-mono text-[15px] whitespace-pre-wrap break-words leading-relaxed">
|
|
<span>{msg.text.substring(0, revealed)}</span>
|
|
<span className="bg-green-500/20 text-green-400 rounded px-0.5">{gibberish.substring(revealed)}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default function ChatArea({ activeView, activeChannel, setActiveChannel, messages, myKey, profile, typingUsers, readReceipts, deliveredReceipts, onlinePeers, markChannelRead, dms, servers, onStartCall, activeCall, onReturnToCall, transfers, onOpenInvite, onToggleMembers, pinMembers, onNavigateToDM }) {
|
|
const[inputText, setInputText] = useState('');
|
|
const[editingId, setEditingId] = useState(null);
|
|
const[editInput, setEditInput] = useState('');
|
|
const[activeTypers, setActiveTypers] = useState([]);
|
|
const[replyingTo, setReplyingTo] = useState(null);
|
|
|
|
const [attachments, setAttachments] = useState([]);
|
|
const[isDragging, setIsDragging] = useState(false);
|
|
const[expandedImage, setExpandedImage] = useState(null);
|
|
const [contextMenu, setContextMenu] = useState(null);
|
|
const [fullEmojiPicker, setFullEmojiPicker] = useState(null);
|
|
const [emojiPickerDirection, setEmojiPickerDirection] = useState('down');
|
|
const [profileViewUser, setProfileViewUser] = useState(null);
|
|
|
|
const [mentionType, setMentionType] = useState(null); // 'user' | 'channel'
|
|
const [mentionQuery, setMentionQuery] = useState(null);
|
|
const [mentionIndex, setMentionIndex] = useState(0);
|
|
const [filteredMentions, setFilteredMentions] = useState([]);
|
|
|
|
const [showCrypto, setShowCrypto] = useState(false);
|
|
const [liveDecryption, setLiveDecryption] = useState(localStorage.getItem('pear_live_decryption') === 'true');
|
|
const [ircMode, setIrcMode] = useState(localStorage.getItem('pear_irc_mode') === 'true');
|
|
const [animationTrigger, setAnimationTrigger] = useState(0);
|
|
|
|
const messagesEndRef = useRef(null);
|
|
const chatContainerRef = useRef(null);
|
|
const textareaRef = useRef(null);
|
|
const editTextareaRef = useRef(null);
|
|
const fileInputRef = useRef(null);
|
|
const lastTypingTime = useRef(0);
|
|
const lastSentReadIdRef = useRef(null);
|
|
|
|
const isDMView = activeView === 'dms';
|
|
const gcObj = isDMView ? servers.find(s => s.topicHex === activeChannel && s.isGroupChat) : null;
|
|
const isGroupChat = !!gcObj;
|
|
|
|
const networkChannelId = isGroupChat ? activeChannel : (isDMView ? activeChannel : `${activeView}-${activeChannel}`);
|
|
|
|
let isAdmin = myKey === ADMIN_PUBLIC_KEY;
|
|
let canPost = true;
|
|
let canUpload = true;
|
|
let canReact = true;
|
|
|
|
if (!isDMView || isGroupChat) {
|
|
const activeServerObj = servers.find(s => s.topicHex === (isGroupChat ? activeChannel : activeView));
|
|
if (activeServerObj) {
|
|
if (activeServerObj.owner === myKey) {
|
|
isAdmin = true;
|
|
}
|
|
|
|
if (!isAdmin) {
|
|
const userRoles = activeServerObj.memberRoles?.[myKey] || [];
|
|
const isServerAdmin = userRoles.some(rId => {
|
|
const r = activeServerObj.roles?.find(role => role.id === rId);
|
|
return r && r.permissions.includes('admin');
|
|
});
|
|
|
|
if (!isServerAdmin) {
|
|
const channelPerms = activeServerObj.channels?.permissions?.[activeChannel];
|
|
|
|
if (channelPerms && channelPerms.length > 0) {
|
|
const hasChannelAccess = userRoles.some(rId => channelPerms.includes(rId));
|
|
if (!hasChannelAccess) {
|
|
canPost = false;
|
|
canUpload = false;
|
|
canReact = false;
|
|
}
|
|
}
|
|
|
|
const channelSendPerms = activeServerObj.channels?.send_permissions?.[activeChannel];
|
|
if (channelSendPerms && channelSendPerms.length > 0) {
|
|
const hasChannelSendAccess = userRoles.some(rId => channelSendPerms.includes(rId));
|
|
if (!hasChannelSendAccess) {
|
|
canPost = false;
|
|
canUpload = false;
|
|
}
|
|
}
|
|
|
|
if (canPost) {
|
|
const hasSendPerm = userRoles.some(rId => {
|
|
const r = activeServerObj.roles?.find(role => role.id === rId);
|
|
return r && r.permissions.includes('send_messages');
|
|
});
|
|
if (!hasSendPerm && activeServerObj.roles && activeServerObj.roles.length > 0) canPost = false;
|
|
}
|
|
|
|
if (canUpload) {
|
|
const hasFilePerm = userRoles.some(rId => {
|
|
const r = activeServerObj.roles?.find(role => role.id === rId);
|
|
return r && r.permissions.includes('send_files');
|
|
});
|
|
if (!hasFilePerm && activeServerObj.roles && activeServerObj.roles.length > 0) canUpload = false;
|
|
}
|
|
|
|
if (canReact) {
|
|
const hasReactPerm = userRoles.some(rId => {
|
|
const r = activeServerObj.roles?.find(role => role.id === rId);
|
|
return r && r.permissions.includes('add_reactions');
|
|
});
|
|
if (!hasReactPerm && activeServerObj.roles && activeServerObj.roles.length > 0) canReact = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const openProfile = (pubKey) => {
|
|
const user = network.knownProfiles.get(pubKey) || { key: pubKey, displayName: 'Unknown User', username: 'unknown' };
|
|
setProfileViewUser({ key: pubKey, ...user });
|
|
};
|
|
|
|
const markdownComponents = useMemo(() => ({
|
|
code({node, inline, className, children, ...props}) {
|
|
const match = /language-(\w+)/.exec(className || '')
|
|
return !inline && match ? (
|
|
<SyntaxHighlighter
|
|
style={vscDarkPlus}
|
|
language={match[1]}
|
|
PreTag="div"
|
|
className="rounded-md !my-2 !bg-base border border-surface text-sm custom-scrollbar"
|
|
{...props}
|
|
>
|
|
{String(children).replace(/\n$/, '')}
|
|
</SyntaxHighlighter>
|
|
) : (
|
|
<code className="bg-base text-text px-1.5 py-0.5 rounded font-mono text-[13px] before:content-none after:content-none" {...props}>
|
|
{children}
|
|
</code>
|
|
)
|
|
},
|
|
a: ({node, href, children, ...props}) => {
|
|
if (href && href.startsWith('mention://')) {
|
|
return (
|
|
<span
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const username = href.replace('mention://@', '');
|
|
if (username === 'everyone') return;
|
|
|
|
let foundPubKey = null;
|
|
for (const [key, profile] of network.knownProfiles.entries()) {
|
|
if (profile.username === username) {
|
|
foundPubKey = key;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (foundPubKey) {
|
|
openProfile(foundPubKey);
|
|
} else {
|
|
const dirUser = network.userDirectory?.get(username);
|
|
if (dirUser && dirUser.pubKey) openProfile(dirUser.pubKey);
|
|
else openProfile(username);
|
|
}
|
|
}}
|
|
className="bg-[#5865F2]/30 !text-[#c9cdfb] hover:bg-[#5865F2] hover:!text-white px-1.5 py-0.5 rounded-md font-medium cursor-pointer transition-colors no-underline"
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
if (href && href.startsWith('channel://')) {
|
|
return (
|
|
<span
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const channelName = href.replace('channel://#', '');
|
|
if (setActiveChannel) setActiveChannel(channelName);
|
|
}}
|
|
className="bg-[#5865F2]/30 !text-[#c9cdfb] hover:bg-[#5865F2] hover:!text-white px-1.5 py-0.5 rounded-md font-medium cursor-pointer transition-colors no-underline"
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
return <a className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer" href={href} {...props}>{children}</a>;
|
|
},
|
|
p: ({node, children, ...props}) => <p className="mb-2 last:mb-0 whitespace-pre-wrap" {...props}>{children}</p>
|
|
}), [setActiveChannel]);
|
|
|
|
useEffect(() => {
|
|
const handleStorage = () => {
|
|
setIrcMode(localStorage.getItem('pear_irc_mode') === 'true');
|
|
setLiveDecryption(localStorage.getItem('pear_live_decryption') === 'true');
|
|
};
|
|
window.addEventListener('storage', handleStorage);
|
|
return () => window.removeEventListener('storage', handleStorage);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
|
|
setReplyingTo(null);
|
|
setMentionQuery(null);
|
|
setMentionType(null);
|
|
},[activeChannel, activeView]);
|
|
|
|
useEffect(() => {
|
|
const handleClick = () => {
|
|
setContextMenu(null);
|
|
setFullEmojiPicker(null);
|
|
};
|
|
if (contextMenu || fullEmojiPicker) document.addEventListener('click', handleClick);
|
|
return () => document.removeEventListener('click', handleClick);
|
|
}, [contextMenu, fullEmojiPicker]);
|
|
|
|
useEffect(() => {
|
|
const handleJump = (e) => {
|
|
const msgId = e.detail;
|
|
const el = document.getElementById(`msg-${msgId}`);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
el.classList.add('bg-accent/20');
|
|
setTimeout(() => el.classList.remove('bg-accent/20'), 2000);
|
|
}
|
|
};
|
|
window.addEventListener('jump-to-message', handleJump);
|
|
return () => window.removeEventListener('jump-to-message', handleJump);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (chatContainerRef.current) {
|
|
const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current;
|
|
const isNearBottom = scrollHeight - scrollTop - clientHeight < 200;
|
|
if (isNearBottom) {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
}
|
|
|
|
const sendRead = () => {
|
|
const currentChannelMessages = messages.filter(m => {
|
|
if (isDMView && !isGroupChat) return (m.sender === myKey && m.recipient === activeChannel) || (m.sender === activeChannel && m.recipient === myKey);
|
|
return m.channel === networkChannelId && !m.recipient;
|
|
});
|
|
|
|
const latestMsg = currentChannelMessages[currentChannelMessages.length - 1];
|
|
const latestMsgId = latestMsg ? latestMsg.id : null;
|
|
|
|
if (latestMsgId && latestMsgId !== lastSentReadIdRef.current) {
|
|
network.sendReadReceipt(networkChannelId, latestMsgId);
|
|
lastSentReadIdRef.current = latestMsgId;
|
|
}
|
|
markChannelRead(networkChannelId);
|
|
};
|
|
|
|
sendRead();
|
|
const interval = setInterval(sendRead, 3000);
|
|
return () => clearInterval(interval);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
},[messages.length, activeChannel, isDMView, isGroupChat, myKey, onlinePeers.length, activeView]);
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
const now = Date.now();
|
|
const typers = Object.entries(typingUsers)
|
|
.filter(([key, info]) => {
|
|
if (now - info.timestamp >= 3000 || key === myKey) return false;
|
|
return (isDMView && !isGroupChat) ? (key === activeChannel && info.channel === myKey) : (info.channel === networkChannelId);
|
|
})
|
|
.map(([_, info]) => info.displayName);
|
|
setActiveTypers(typers);
|
|
}, 1000);
|
|
return () => clearInterval(interval);
|
|
},[typingUsers, activeChannel, myKey, isDMView, isGroupChat, networkChannelId]);
|
|
|
|
useEffect(() => {
|
|
if (editingId && editTextareaRef.current) {
|
|
editTextareaRef.current.style.height = 'auto';
|
|
editTextareaRef.current.style.height = `${Math.min(editTextareaRef.current.scrollHeight, 400)}px`;
|
|
editTextareaRef.current.focus();
|
|
}
|
|
},[editingId]);
|
|
|
|
useEffect(() => {
|
|
if (mentionQuery !== null) {
|
|
if (mentionType === 'user') {
|
|
let users = [];
|
|
if (isDMView && !isGroupChat) {
|
|
const dmUser = dms[activeChannel]?.profile || { displayName: 'Unknown', username: 'unknown' };
|
|
users = [dmUser];
|
|
} else {
|
|
const serverObj = servers.find(s => s.topicHex === (isGroupChat ? activeChannel : activeView));
|
|
if (serverObj) {
|
|
const members = network.serverMembers[serverObj.topicHex] ? Array.from(network.serverMembers[serverObj.topicHex]) : [];
|
|
if (!members.includes(serverObj.owner)) members.push(serverObj.owner);
|
|
users = members.map(k => network.knownProfiles.get(k)).filter(Boolean);
|
|
}
|
|
}
|
|
|
|
const query = mentionQuery.toLowerCase();
|
|
const filtered = users.filter(u => u.username.toLowerCase().includes(query) || u.displayName.toLowerCase().includes(query));
|
|
|
|
let canMentionEveryone = isAdmin;
|
|
if (!isAdmin && !isDMView) {
|
|
const serverObj = servers.find(s => s.topicHex === activeView);
|
|
if (serverObj) {
|
|
const userRoles = serverObj.memberRoles?.[myKey] || [];
|
|
canMentionEveryone = userRoles.some(rId => {
|
|
const r = serverObj.roles?.find(role => role.id === rId);
|
|
return r && (r.permissions.includes('admin') || r.permissions.includes('mention_everyone'));
|
|
});
|
|
}
|
|
}
|
|
|
|
if ('everyone'.includes(query) && (canMentionEveryone || isDMView)) {
|
|
filtered.unshift({ username: 'everyone', displayName: 'Everyone in this channel', avatar: null, isSpecial: true });
|
|
}
|
|
|
|
setFilteredMentions(filtered);
|
|
} else if (mentionType === 'channel') {
|
|
const serverObj = servers.find(s => s.topicHex === activeView);
|
|
if (serverObj && serverObj.channels) {
|
|
const allChannels = [
|
|
...(serverObj.channels.text || []).map(ch => ({ isChannel: true, name: ch, type: 'text' })),
|
|
...(serverObj.channels.voice || []).map(ch => ({ isChannel: true, name: ch, type: 'voice' }))
|
|
];
|
|
const query = mentionQuery.toLowerCase();
|
|
const filtered = allChannels.filter(ch => ch.name.toLowerCase().includes(query));
|
|
setFilteredMentions(filtered);
|
|
} else {
|
|
setFilteredMentions([]);
|
|
}
|
|
}
|
|
setMentionIndex(0);
|
|
}
|
|
}, [mentionQuery, mentionType, activeView, activeChannel, isDMView, isGroupChat, dms, servers, isAdmin, myKey]);
|
|
|
|
const processFiles = async (files) => {
|
|
const newAttachments =[];
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
newAttachments.push({
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
path: file.path,
|
|
fileObj: file,
|
|
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
|
});
|
|
}
|
|
setAttachments(prev =>[...prev, ...newAttachments]);
|
|
};
|
|
|
|
const handleDrop = (e) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
if (canUpload && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
processFiles(e.dataTransfer.files);
|
|
}
|
|
};
|
|
|
|
const handlePaste = (e) => {
|
|
if (canUpload && e.clipboardData.files && e.clipboardData.files.length > 0) {
|
|
processFiles(e.clipboardData.files);
|
|
}
|
|
};
|
|
|
|
const handleInputChange = (e) => {
|
|
const val = e.target.value;
|
|
setInputText(val);
|
|
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 400)}px`;
|
|
}
|
|
|
|
const cursor = e.target.selectionStart;
|
|
const textBeforeCursor = val.slice(0, cursor);
|
|
|
|
const userMatch = textBeforeCursor.match(/(^|\s)@([a-zA-Z0-9_.]*)$/);
|
|
const channelMatch = textBeforeCursor.match(/(^|\s)#([a-z0-9-]*)$/);
|
|
|
|
if (userMatch) {
|
|
setMentionType('user');
|
|
setMentionQuery(userMatch[2]);
|
|
} else if (channelMatch && !isDMView) {
|
|
setMentionType('channel');
|
|
setMentionQuery(channelMatch[2]);
|
|
} else {
|
|
setMentionType(null);
|
|
setMentionQuery(null);
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now - lastTypingTime.current > 2000) {
|
|
network.sendTyping(networkChannelId);
|
|
lastTypingTime.current = now;
|
|
}
|
|
};
|
|
|
|
const insertMention = (item) => {
|
|
if (!item) return;
|
|
const cursor = textareaRef.current.selectionStart;
|
|
const textBeforeCursor = inputText.slice(0, cursor);
|
|
const textAfterCursor = inputText.slice(cursor);
|
|
|
|
if (mentionType === 'user') {
|
|
const match = textBeforeCursor.match(/(^|\s)@([a-zA-Z0-9_.]*)$/);
|
|
if (match) {
|
|
const newTextBefore = textBeforeCursor.slice(0, match.index) + match[1] + `@${item.username} `;
|
|
setInputText(newTextBefore + textAfterCursor);
|
|
setMentionQuery(null);
|
|
setMentionType(null);
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
textareaRef.current.selectionStart = newTextBefore.length;
|
|
textareaRef.current.selectionEnd = newTextBefore.length;
|
|
}
|
|
}, 0);
|
|
}
|
|
} else if (mentionType === 'channel') {
|
|
const match = textBeforeCursor.match(/(^|\s)#([a-z0-9-]*)$/);
|
|
if (match) {
|
|
const newTextBefore = textBeforeCursor.slice(0, match.index) + match[1] + `#${item.name} `;
|
|
setInputText(newTextBefore + textAfterCursor);
|
|
setMentionQuery(null);
|
|
setMentionType(null);
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
textareaRef.current.selectionStart = newTextBefore.length;
|
|
textareaRef.current.selectionEnd = newTextBefore.length;
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
if (mentionQuery !== null && filteredMentions.length > 0) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setMentionIndex(prev => (prev + 1) % filteredMentions.length);
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setMentionIndex(prev => (prev - 1 + filteredMentions.length) % filteredMentions.length);
|
|
return;
|
|
}
|
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
insertMention(filteredMentions[mentionIndex]);
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
setMentionQuery(null);
|
|
setMentionType(null);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
|
|
const replyId = replyingTo ? replyingTo.id : null;
|
|
|
|
if (attachments.length > 0) {
|
|
for (let i = 0; i < attachments.length; i++) {
|
|
const textToSend = i === 0 ? inputText.trim() : '';
|
|
if (isDMView && !isGroupChat) network.sendDMFile(activeChannel, textToSend, attachments[i]);
|
|
else network.sendFile(networkChannelId, textToSend, attachments[i]);
|
|
}
|
|
setAttachments([]);
|
|
setInputText('');
|
|
setReplyingTo(null);
|
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
|
} else if (inputText.trim() !== '') {
|
|
if (isDMView && !isGroupChat) network.sendDM(activeChannel, inputText.trim(), replyId);
|
|
else network.sendMessage(networkChannelId, inputText.trim(), replyId);
|
|
setInputText('');
|
|
setReplyingTo(null);
|
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
|
}
|
|
}
|
|
};
|
|
|
|
const startEditing = (msg) => {
|
|
setEditingId(msg.id);
|
|
setEditInput(msg.text);
|
|
};
|
|
|
|
const handleEditChange = (e) => {
|
|
setEditInput(e.target.value);
|
|
if (editTextareaRef.current) {
|
|
editTextareaRef.current.style.height = 'auto';
|
|
editTextareaRef.current.style.height = `${Math.min(editTextareaRef.current.scrollHeight, 400)}px`;
|
|
}
|
|
};
|
|
|
|
const handleEditMessage = (e, id) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (editInput.trim() !== '') network.sendEditMessage(id, editInput.trim());
|
|
setEditingId(null);
|
|
} else if (e.key === 'Escape') {
|
|
setEditingId(null);
|
|
}
|
|
};
|
|
|
|
const handleOpenFolder = async (filePath) => {
|
|
if (!filePath) return;
|
|
try {
|
|
if (typeof Pear !== 'undefined') {
|
|
const { spawn } = await import('child_process');
|
|
const os = await import('os');
|
|
const path = await import('path');
|
|
const platform = os.platform();
|
|
|
|
if (platform === 'win32') {
|
|
const child = spawn('explorer.exe',['/select,', filePath], { detached: true });
|
|
child.unref();
|
|
} else if (platform === 'darwin') {
|
|
const child = spawn('open',['-R', filePath], { detached: true });
|
|
child.unref();
|
|
} else {
|
|
const dir = path.dirname(filePath);
|
|
const child = spawn('xdg-open',[dir], { detached: true });
|
|
child.unref();
|
|
}
|
|
} else if (typeof window !== 'undefined' && window.require) {
|
|
const { shell } = window.require('electron');
|
|
shell.showItemInFolder(filePath);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to open folder:", err.message || err);
|
|
}
|
|
};
|
|
|
|
const handleCopyText = (text) => {
|
|
if (text) navigator.clipboard.writeText(text);
|
|
};
|
|
|
|
const handleCopyImage = async (url) => {
|
|
try {
|
|
const response = await fetch(url);
|
|
const blob = await response.blob();
|
|
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
|
} catch (err) {
|
|
console.error("Failed to copy image", err);
|
|
}
|
|
};
|
|
|
|
const handleToggleLiveDecryption = () => {
|
|
const newVal = !liveDecryption;
|
|
setLiveDecryption(newVal);
|
|
localStorage.setItem('pear_live_decryption', newVal);
|
|
if (newVal) {
|
|
setAnimationTrigger(Date.now());
|
|
}
|
|
};
|
|
|
|
const currentChannelMessages = messages.filter(m => {
|
|
if (isDMView && !isGroupChat) return (m.sender === myKey && m.recipient === activeChannel) || (m.sender === activeChannel && m.recipient === myKey);
|
|
return m.channel === networkChannelId && !m.recipient;
|
|
});
|
|
|
|
const myMessages = currentChannelMessages.filter(m => m.sender === myKey);
|
|
const lastMyMessageId = myMessages.length > 0 ? myMessages[myMessages.length - 1].id : null;
|
|
|
|
const readMsgId = readReceipts[networkChannelId];
|
|
const explicitDeliveredMsgId = deliveredReceipts ? deliveredReceipts[networkChannelId] : null;
|
|
|
|
const readMsgIndex = currentChannelMessages.findIndex(m => m.id === readMsgId);
|
|
const explicitDeliveredMsgIndex = currentChannelMessages.findIndex(m => m.id === explicitDeliveredMsgId);
|
|
|
|
const isPeerOnline = (isDMView && !isGroupChat) ? onlinePeers.some(p => p.key === activeChannel) : onlinePeers.length > 0;
|
|
|
|
let effectiveDeliveredMsgIndex = explicitDeliveredMsgIndex;
|
|
if (isPeerOnline && isDMView && !isGroupChat) effectiveDeliveredMsgIndex = currentChannelMessages.length - 1;
|
|
|
|
let lastMyReadMsgId = null;
|
|
let lastMyDeliveredMsgId = null;
|
|
|
|
for (let i = currentChannelMessages.length - 1; i >= 0; i--) {
|
|
const m = currentChannelMessages[i];
|
|
if (m.sender === myKey) {
|
|
if (!lastMyDeliveredMsgId && effectiveDeliveredMsgIndex !== -1 && i <= effectiveDeliveredMsgIndex) lastMyDeliveredMsgId = m.id;
|
|
if (!lastMyReadMsgId && readMsgIndex !== -1 && i <= readMsgIndex) lastMyReadMsgId = m.id;
|
|
}
|
|
}
|
|
|
|
const getMessageStatus = (msg) => {
|
|
if (msg.sender !== myKey) return null;
|
|
const msgIndex = currentChannelMessages.findIndex(m => m.id === msg.id);
|
|
|
|
if (isDMView && !isGroupChat) {
|
|
if (msg.id === lastMyReadMsgId) {
|
|
const hasNewerReply = currentChannelMessages.slice(msgIndex + 1).some(m => m.sender !== myKey);
|
|
if (!hasNewerReply) {
|
|
const targetProfile = dms[activeChannel]?.profile || {};
|
|
return (
|
|
<div className="w-[14px] h-[14px] rounded-full overflow-hidden inline-block ml-2 align-middle bg-surface border border-panel" title="Read">
|
|
{targetProfile.avatar ? (
|
|
<img src={targetProfile.avatar} className="w-full h-full object-cover rounded-full" />
|
|
) : (
|
|
<div className="w-full h-full bg-indigo-500 rounded-full flex items-center justify-center text-[6px] text-white font-bold">
|
|
{targetProfile.displayName?.substring(0, 2).toUpperCase() || '?'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
const isAfterRead = readMsgIndex === -1 || msgIndex > readMsgIndex;
|
|
if (isAfterRead) {
|
|
const isDelivered = effectiveDeliveredMsgIndex !== -1 && msgIndex <= effectiveDeliveredMsgIndex;
|
|
if (isDelivered) return <span className="text-blue-400 text-[10px] ml-2 uppercase font-bold tracking-wider" title="Delivered">✓✓</span>;
|
|
else return <span className="text-muted text-[10px] ml-2 uppercase font-bold tracking-wider" title="Sent">✓</span>;
|
|
}
|
|
return null;
|
|
} else {
|
|
if (msg.id !== lastMyMessageId) return null;
|
|
const isRead = readMsgIndex !== -1 && msgIndex <= readMsgIndex;
|
|
if (isRead) return <span className="text-blue-400 ml-2 text-xs" title="Read">✓✓</span>;
|
|
if (isPeerOnline) return <span className="text-muted ml-2 text-xs" title="Delivered">✓✓</span>;
|
|
return <span className="text-muted/50 ml-2 text-xs" title="Sent">✓</span>;
|
|
}
|
|
};
|
|
|
|
const headerName = isGroupChat ? gcObj.name : (isDMView ? (dms[activeChannel]?.profile?.displayName || 'Unknown') : activeChannel);
|
|
const headerIcon = isGroupChat ? '👥' : (isDMView ? '@' : '#');
|
|
|
|
let typingText = '';
|
|
if (activeTypers.length === 1) typingText = `${activeTypers[0]} is typing...`;
|
|
else if (activeTypers.length > 1) typingText = `Several people are typing...`;
|
|
|
|
const isCallActiveInThisDM = activeCall && activeCall.targetKey === activeChannel;
|
|
|
|
return (
|
|
<div
|
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
|
onDragLeave={() => setIsDragging(false)}
|
|
onDrop={handleDrop}
|
|
className="flex-1 flex flex-col bg-panel min-w-0 relative h-full"
|
|
>
|
|
{isDragging && canUpload && (
|
|
<div className="absolute inset-0 z-50 bg-accent/90 flex items-center justify-center backdrop-blur-sm m-4 rounded-xl border-2 border-dashed border-white pointer-events-none">
|
|
<div className="text-center text-white">
|
|
<svg className="w-20 h-20 mx-auto mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
|
|
<h2 className="text-3xl font-bold">Drop files to upload</h2>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{expandedImage && (
|
|
<div className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center backdrop-blur-sm" onClick={() => setExpandedImage(null)}>
|
|
<img src={expandedImage} className="max-w-[90vw] max-h-[90vh] object-contain" onClick={(e) => e.stopPropagation()} />
|
|
<button className="absolute top-6 right-6 text-white hover:text-gray-300" onClick={() => setExpandedImage(null)}>
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{profileViewUser && (
|
|
<UserProfileModal
|
|
user={profileViewUser}
|
|
onClose={() => setProfileViewUser(null)}
|
|
onSendDM={profileViewUser.key !== myKey ? (u) => {
|
|
if (!dms[u.key]) {
|
|
network.sendDMRequest(u.key, { displayName: u.displayName, username: u.username, avatar: u.avatar, bio: u.bio, connections: u.connections });
|
|
}
|
|
if (onNavigateToDM) onNavigateToDM(u.key);
|
|
} : null}
|
|
/>
|
|
)}
|
|
|
|
{contextMenu && (
|
|
<div className="fixed inset-0 z-50" onClick={() => setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}>
|
|
<div
|
|
className="absolute bg-panel border border-surface shadow-xl rounded py-1.5 w-48 flex flex-col"
|
|
style={{
|
|
top: Math.min(contextMenu.y, window.innerHeight - 200),
|
|
left: Math.min(contextMenu.x, window.innerWidth - 200)
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{canReact && (
|
|
<div className="flex justify-between px-3 py-1.5 border-b border-surface mb-1">
|
|
{QUICK_EMOJIS.map(emoji => (
|
|
<button
|
|
key={emoji}
|
|
onClick={() => {
|
|
network.sendReaction(contextMenu.msg.id, emoji, isDMView && !isGroupChat, activeChannel);
|
|
setContextMenu(null);
|
|
}}
|
|
className="hover:bg-surface rounded p-1 transition-colors"
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{canPost && (
|
|
<button
|
|
className="w-full text-left px-3 py-1.5 text-sm text-text hover:bg-accent hover:text-white transition-colors"
|
|
onClick={() => {
|
|
setReplyingTo(contextMenu.msg);
|
|
textareaRef.current?.focus();
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
Reply
|
|
</button>
|
|
)}
|
|
|
|
{contextMenu.msg.text && (
|
|
<button
|
|
className="w-full text-left px-3 py-1.5 text-sm text-text hover:bg-accent hover:text-white transition-colors"
|
|
onClick={() => {
|
|
handleCopyText(contextMenu.msg.text);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
Copy Text
|
|
</button>
|
|
)}
|
|
|
|
{contextMenu.msg.payload?.type === 'file' && contextMenu.msg.payload.file.mimeType?.startsWith('image/') && (
|
|
<button
|
|
className="w-full text-left px-3 py-1.5 text-sm text-text hover:bg-accent hover:text-white transition-colors"
|
|
onClick={() => {
|
|
const url = contextMenu.msg.localBlobUrl || `peercord://local/${encodeURIComponent(contextMenu.msg.localPath.replace(/\\/g, '/'))}`;
|
|
handleCopyImage(url);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
Copy Image
|
|
</button>
|
|
)}
|
|
|
|
{contextMenu.isMe && contextMenu.msg.payload?.type !== 'server_invite' && (
|
|
<button
|
|
className="w-full text-left px-3 py-1.5 text-sm text-text hover:bg-accent hover:text-white transition-colors"
|
|
onClick={() => {
|
|
startEditing(contextMenu.msg);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
Edit Message
|
|
</button>
|
|
)}
|
|
{(contextMenu.isAdmin || contextMenu.isMe) && (
|
|
<button
|
|
className="w-full text-left px-3 py-1.5 text-sm text-red-500 hover:bg-red-500 hover:text-white transition-colors"
|
|
onClick={() => {
|
|
network.sendDeleteMessage(contextMenu.msg.id);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
Delete Message
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="h-14 shadow-sm flex items-center px-4 border-b border-base gap-3 shrink-0 bg-panel z-10">
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<span className="text-muted text-2xl shrink-0">{headerIcon}</span>
|
|
<span className="font-bold text-text truncate">{headerName}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0 ml-4">
|
|
{isDMView && !isGroupChat && (
|
|
<>
|
|
<button
|
|
onClick={handleToggleLiveDecryption}
|
|
className={`p-2 rounded transition-colors flex items-center gap-2 text-xs font-bold ${liveDecryption ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
|
|
title="Toggle Live Decryption Animation"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12h4l3-9 5 18 3-9h5"></path></svg>
|
|
{liveDecryption ? 'Live Decryption ON' : 'Live Decryption OFF'}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowCrypto(!showCrypto)}
|
|
className={`p-2 rounded transition-colors flex items-center gap-2 text-xs font-bold ${showCrypto ? 'bg-green-500/20 text-green-500' : 'text-muted hover:text-text'}`}
|
|
title="Toggle Developer Crypto Mode"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
|
{showCrypto ? 'E2EE Verified' : 'Verify E2EE'}
|
|
</button>
|
|
</>
|
|
)}
|
|
{(isDMView || isGroupChat) && (
|
|
<>
|
|
{isGroupChat && (
|
|
<button onClick={() => onOpenInvite(activeChannel)} className="text-muted hover:text-text p-2 transition-colors" title="Add Contacts">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5c-2.2 0-4 1.8-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
|
|
</button>
|
|
)}
|
|
{isCallActiveInThisDM ? (
|
|
<button
|
|
onClick={onReturnToCall}
|
|
className="bg-accent hover:opacity-90 text-white px-3 py-1 rounded text-sm font-bold transition-opacity flex items-center gap-2"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
|
|
Return to Call
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => onStartCall(activeChannel, 'voice')}
|
|
className="text-muted hover:text-text p-2 transition-colors"
|
|
title="Start Voice Call"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
|
|
</button>
|
|
<button
|
|
onClick={() => onStartCall(activeChannel, 'video')}
|
|
className="text-muted hover:text-text p-2 transition-colors"
|
|
title="Start Video Call"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect></svg>
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
{(!isDMView || isGroupChat) && !pinMembers && (
|
|
<button onClick={onToggleMembers} className="text-muted hover:text-text p-2 transition-colors" title="Toggle Members">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 p-4 overflow-y-auto flex flex-col" ref={chatContainerRef}>
|
|
<div className="mt-auto"></div>
|
|
<div className="text-center my-8">
|
|
<div className="w-20 h-20 bg-surface rounded-full mx-auto mb-4 flex items-center justify-center text-4xl text-text">{headerIcon}</div>
|
|
<h1 className="text-3xl font-bold text-text mb-2">{isGroupChat ? `Welcome to ${headerName}!` : (isDMView ? headerName : `Welcome to #${headerName}!`)}</h1>
|
|
<p className="text-muted">
|
|
{isGroupChat ? `This is the beginning of your group whisper history.` : (isDMView ? `This is the beginning of your whisper history with ${headerName}.` : `This is the start of the decentralized #${headerName} room.`)}
|
|
</p>
|
|
</div>
|
|
|
|
{currentChannelMessages.map((msg) => {
|
|
const isPlatformAdmin = msg.sender === ADMIN_PUBLIC_KEY;
|
|
let isServerOwner = false;
|
|
let canDelete = false;
|
|
|
|
if (!isDMView || isGroupChat) {
|
|
const activeServerObj = servers.find(s => s.topicHex === (isGroupChat ? activeChannel : activeView));
|
|
if (activeServerObj) {
|
|
if (activeServerObj.owner === msg.sender) isServerOwner = true;
|
|
if (activeServerObj.owner === myKey) canDelete = true;
|
|
else {
|
|
const userRoles = activeServerObj.memberRoles?.[myKey] || [];
|
|
const hasAdmin = userRoles.some(rId => {
|
|
const r = activeServerObj.roles?.find(role => role.id === rId);
|
|
return r && r.permissions.includes('admin');
|
|
});
|
|
if (hasAdmin) canDelete = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const showCrown = isPlatformAdmin || isServerOwner;
|
|
const crownTitle = isServerOwner ? (isGroupChat ? "Group Creator" : "Hub Owner") : "Platform Admin";
|
|
const isMe = msg.sender === myKey;
|
|
|
|
if (ircMode) {
|
|
return (
|
|
<div
|
|
key={msg.id}
|
|
id={`msg-${msg.id}`}
|
|
className="flex gap-2 w-full hover:bg-panel/40 px-4 py-0.5 group/msg relative transition-colors"
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
setContextMenu({ x: e.pageX, y: e.pageY, msg, isMe, isAdmin: canDelete || isPlatformAdmin });
|
|
}}
|
|
>
|
|
<span className="text-muted text-xs font-mono shrink-0 mt-0.5">[{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}]</span>
|
|
<span className="font-bold text-text shrink-0 cursor-pointer hover:underline" onClick={() => openProfile(msg.sender)}>
|
|
<{isMe ? `${profile.displayName}` : msg.senderName}>
|
|
</span>
|
|
<div className="text-text text-[14px] leading-relaxed flex-1 min-w-0">
|
|
{msg.replyTo && (() => {
|
|
const repliedMsg = messages.find(m => m.id === msg.replyTo);
|
|
if (repliedMsg) return <span className="text-accent mr-2 cursor-pointer hover:underline" onClick={() => document.getElementById(`msg-${repliedMsg.id}`)?.scrollIntoView({behavior: 'smooth'})}>@{repliedMsg.senderName}</span>;
|
|
return null;
|
|
})()}
|
|
{msg.payload?.type === 'file' ? (
|
|
<span className="text-accent italic">[File: {msg.payload.file.name}]</span>
|
|
) : msg.payload?.type === 'server_invite' ? (
|
|
<span className="text-accent italic">[Server Invite: {msg.payload.serverName}]</span>
|
|
) : (
|
|
<span className="whitespace-pre-wrap break-words">{msg.text}</span>
|
|
)}
|
|
{msg.edited && <span className="text-[10px] text-muted ml-2">(edited)</span>}
|
|
</div>
|
|
|
|
{/* Actions for IRC Mode */}
|
|
<div className={`absolute right-4 -top-3 flex flex-col items-end ${fullEmojiPicker === msg.id ? 'opacity-100 z-50' : (fullEmojiPicker ? 'opacity-0 pointer-events-none z-10' : 'opacity-0 group-hover/msg:opacity-100 z-10')}`}>
|
|
<div className="flex items-center bg-surface border border-panel rounded-md shadow-sm overflow-hidden">
|
|
{canReact && QUICK_EMOJIS.map(emoji => (
|
|
<button
|
|
key={emoji}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
network.sendReaction(msg.id, emoji, isDMView && !isGroupChat, activeChannel);
|
|
}}
|
|
className="hover:bg-panel p-1.5 transition-colors text-base"
|
|
title="React"
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
{canReact && (
|
|
<div className="relative">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setFullEmojiPicker(fullEmojiPicker === msg.id ? null : msg.id);
|
|
setEmojiPickerDirection(e.clientY > window.innerHeight / 2 ? 'up' : 'down');
|
|
}}
|
|
className="text-muted hover:text-text hover:bg-panel p-2 transition-colors"
|
|
title="Add Reaction"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M8 14s1.5 2 4 2 4-2 4-2"></path><line x1="9" y1="9" x2="9.01" y2="9"></line><line x1="15" y1="9" x2="15.01" y2="9"></line></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
{canPost && (
|
|
<button onClick={(e) => { e.stopPropagation(); setReplyingTo(msg); textareaRef.current?.focus(); }} className="text-muted hover:text-text hover:bg-panel p-2 transition-colors" title="Reply">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg>
|
|
</button>
|
|
)}
|
|
{isMe && msg.payload?.type !== 'server_invite' && (
|
|
<button onClick={(e) => { e.stopPropagation(); startEditing(msg); }} className="text-muted hover:text-text hover:bg-panel p-2 transition-colors" title="Edit">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
|
</button>
|
|
)}
|
|
{(canDelete || isPlatformAdmin || isMe) && (
|
|
<button onClick={(e) => { e.stopPropagation(); network.sendDeleteMessage(msg.id); }} className="text-red-500 hover:text-red-400 hover:bg-panel p-2 transition-colors" title="Delete">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{fullEmojiPicker === msg.id && (
|
|
<div className={`absolute right-0 ${emojiPickerDirection === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'} bg-panel border border-surface rounded shadow-xl p-2 w-64 h-48 overflow-y-auto custom-scrollbar z-50 grid grid-cols-6 gap-1`} onClick={e => e.stopPropagation()}>
|
|
{ALL_EMOJIS.map(emoji => (
|
|
<button
|
|
key={emoji}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
network.sendReaction(msg.id, emoji, isDMView && !isGroupChat, activeChannel);
|
|
setFullEmojiPicker(null);
|
|
}}
|
|
className="hover:bg-surface rounded p-1 text-lg transition-colors"
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={msg.id}
|
|
id={`msg-${msg.id}`}
|
|
className="flex flex-col w-full hover:bg-panel/40 px-4 py-2 mt-1 group/msg relative transition-colors"
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
setContextMenu({ x: e.pageX, y: e.pageY, msg, isMe, isAdmin: canDelete || isPlatformAdmin });
|
|
}}
|
|
>
|
|
{/* Reply Row */}
|
|
{msg.replyTo && (() => {
|
|
const repliedMsg = messages.find(m => m.id === msg.replyTo);
|
|
if (!repliedMsg) return <div className="text-[10px] text-muted italic mb-1 pl-[72px]">Replying to a deleted message</div>;
|
|
return (
|
|
<div
|
|
className="flex items-center gap-1.5 text-sm text-muted mb-1 relative pl-[72px] cursor-pointer hover:text-text select-none"
|
|
onClick={() => document.getElementById(`msg-${repliedMsg.id}`)?.scrollIntoView({behavior: 'smooth'})}
|
|
>
|
|
<div className="absolute left-[35px] top-[12px] w-[33px] h-[14px] border-l-2 border-t-2 border-muted/50 rounded-tl-md"></div>
|
|
{repliedMsg.senderAvatar ? (
|
|
<img src={repliedMsg.senderAvatar} className="w-4 h-4 rounded-full object-cover shrink-0" />
|
|
) : (
|
|
<div className="w-4 h-4 rounded-full bg-indigo-500 flex items-center justify-center text-[8px] text-white font-bold shrink-0">
|
|
{repliedMsg.senderName.substring(0, 2).toUpperCase()}
|
|
</div>
|
|
)}
|
|
<span className="font-bold text-text/80 hover:underline">@{repliedMsg.senderName}</span>
|
|
<span className="truncate max-w-md">{repliedMsg.text || 'Attachment'}</span>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Main Message Row */}
|
|
<div className="flex gap-4">
|
|
<div
|
|
className={`w-10 h-10 rounded-full shrink-0 flex items-center justify-center text-white font-bold overflow-hidden mt-0.5 cursor-pointer ${msg.senderAvatar ? 'bg-transparent' : 'bg-indigo-500'}`}
|
|
onClick={() => openProfile(msg.sender)}
|
|
>
|
|
{msg.senderAvatar ? <img src={msg.senderAvatar} className="w-full h-full object-cover" /> : msg.senderName.substring(0, 2).toUpperCase()}
|
|
</div>
|
|
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<div className="flex items-baseline gap-2 mb-1">
|
|
<span className="font-medium text-text flex items-center gap-1 cursor-pointer hover:underline" onClick={() => openProfile(msg.sender)}>
|
|
{isMe ? `${profile.displayName} (You)` : msg.senderName}
|
|
{showCrown && <span title={crownTitle} className="text-yellow-500 text-xs">👑</span>}
|
|
</span>
|
|
<span className="text-xs text-muted">{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
{getMessageStatus(msg)}
|
|
</div>
|
|
|
|
<div className="text-text text-[15px] leading-relaxed">
|
|
{showCrypto && msg.isEncrypted ? (
|
|
<div className="bg-black/50 border border-green-500/30 p-3 rounded-md font-mono text-[11px] text-green-400 break-all my-1">
|
|
<div className="text-green-500/50 mb-1 select-none">Algorithm: xchacha20poly1305_ietf</div>
|
|
<div className="text-green-500/50 mb-1 select-none">Nonce: {msg.nonce}</div>
|
|
<div className="select-all">{msg.cipher}</div>
|
|
</div>
|
|
) : editingId === msg.id ? (
|
|
<div className="mt-1 min-w-[200px]">
|
|
<textarea
|
|
ref={editTextareaRef}
|
|
value={editInput}
|
|
onChange={handleEditChange}
|
|
onKeyDown={(e) => handleEditMessage(e, msg.id)}
|
|
className="w-full bg-base text-text rounded p-2 outline-none resize-none max-h-[50vh] custom-scrollbar border border-surface"
|
|
rows={1}
|
|
/>
|
|
<span className="text-[10px] text-muted mt-1 block">escape to cancel • enter to save • shift+enter for new line</span>
|
|
</div>
|
|
) : msg.payload?.type === 'server_invite' ? (
|
|
<div className="mt-1 mb-1">
|
|
<ServerInviteCard invite={msg.payload} joinedServers={servers} />
|
|
</div>
|
|
) : msg.payload?.type === 'file' ? (
|
|
<div className="flex flex-col w-full min-w-0">
|
|
{msg.text && (
|
|
<DecryptedMessage msg={msg} liveDecryption={liveDecryption} animationTrigger={animationTrigger} components={markdownComponents} />
|
|
)}
|
|
|
|
{(() => {
|
|
const fileMeta = msg.payload.file;
|
|
const transfer = transfers[msg.id];
|
|
const isImage = fileMeta.mimeType?.startsWith('image/');
|
|
const isVideo = fileMeta.mimeType?.startsWith('video/');
|
|
|
|
const isComplete = !!msg.localPath || !!msg.localBlobUrl;
|
|
const isSender = msg.sender === myKey;
|
|
|
|
const stateText = transfer ? (
|
|
transfer.state === 'processing' ? 'Processing Local File...' :
|
|
transfer.state === 'uploading' ? 'Uploading to Peer...' :
|
|
transfer.state === 'downloading' ? 'Downloading...' : 'Complete'
|
|
) : 'Waiting for peer...';
|
|
|
|
if (!isComplete) {
|
|
return (
|
|
<div className="bg-panel p-4 rounded-lg border border-surface w-80 mt-1">
|
|
<div className="flex justify-between text-xs text-muted mb-2">
|
|
<span className="font-bold text-text truncate pr-2">{fileMeta.name}</span>
|
|
<span>{transfer ? Math.round(transfer.progress * 100) + '%' : '0%'}</span>
|
|
</div>
|
|
<div className="w-full bg-base rounded-full h-2 mb-2 overflow-hidden">
|
|
<div className="bg-accent h-2 rounded-full transition-all duration-300" style={{ width: `${transfer ? transfer.progress * 100 : 0}%` }}></div>
|
|
</div>
|
|
<div className="flex justify-between text-[10px] text-muted items-center">
|
|
<span>{formatBytes(fileMeta.size)}</span>
|
|
<div className="flex items-center gap-2">
|
|
<span>{stateText}</span>
|
|
{transfer && transfer.state !== 'completed' && transfer.state !== 'processing' && (
|
|
<span className="text-muted/70">• {formatBytes(transfer.speed)}/s</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} else {
|
|
if (isImage || isVideo) {
|
|
const fileUrl = msg.localBlobUrl ? msg.localBlobUrl : `peercord://local/${encodeURIComponent(msg.localPath.replace(/\\/g, '/'))}`;
|
|
|
|
return (
|
|
<div className="mt-1 flex flex-col gap-1">
|
|
{isImage && (
|
|
<img src={fileUrl} alt={fileMeta.name} className="max-w-sm max-h-80 rounded-lg object-contain cursor-pointer border border-surface bg-base" onClick={() => setExpandedImage(fileUrl)} />
|
|
)}
|
|
{isVideo && (
|
|
<video
|
|
src={fileUrl}
|
|
controls
|
|
preload="metadata"
|
|
className="max-w-md max-h-96 rounded-lg border border-surface bg-black"
|
|
/>
|
|
)}
|
|
{transfer && transfer.state === 'uploading' && transfer.progress < 1 && (
|
|
<div className="flex items-center gap-2 text-[10px] text-muted mt-1 bg-panel p-2 rounded w-fit border border-surface">
|
|
<div className="w-24 bg-base rounded-full h-1.5 overflow-hidden">
|
|
<div className="bg-accent h-1.5 rounded-full transition-all duration-300" style={{ width: `${transfer.progress * 100}%` }}></div>
|
|
</div>
|
|
<span>{transfer.progress > 0 ? `Uploading to peer... ${Math.round(transfer.progress * 100)}% • ${formatBytes(transfer.speed)}/s` : 'Seeding file...'}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} else {
|
|
if (isSender) {
|
|
return (
|
|
<div className="mt-1 flex flex-col gap-1">
|
|
<div className="bg-panel p-3 rounded-lg border border-surface flex items-center gap-3 w-80">
|
|
<div className="w-10 h-10 bg-base rounded flex items-center justify-center text-text shrink-0">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
|
|
</div>
|
|
<div className="flex flex-col overflow-hidden">
|
|
<span className="text-sm font-bold text-text truncate">{fileMeta.name}</span>
|
|
<span className="text-xs text-muted">{formatBytes(fileMeta.size)} • Sent File</span>
|
|
</div>
|
|
</div>
|
|
{transfer && transfer.state === 'uploading' && transfer.progress < 1 && (
|
|
<div className="flex items-center gap-2 text-[10px] text-muted mt-1 bg-panel p-2 rounded w-fit border border-surface">
|
|
<div className="w-24 bg-base rounded-full h-1.5 overflow-hidden">
|
|
<div className="bg-accent h-1.5 rounded-full transition-all duration-300" style={{ width: `${transfer.progress * 100}%` }}></div>
|
|
</div>
|
|
<span>{transfer.progress > 0 ? `Uploading to peer... ${Math.round(transfer.progress * 100)}% • ${formatBytes(transfer.speed)}/s` : 'Seeding file...'}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} else {
|
|
return (
|
|
<div className="mt-1 flex flex-col gap-1">
|
|
<div className="bg-panel p-3 rounded-lg border border-surface flex items-center gap-3 w-80 cursor-pointer hover:bg-base transition-colors" onClick={() => handleOpenFolder(msg.localPath)}>
|
|
<div className="w-10 h-10 bg-base rounded flex items-center justify-center text-text shrink-0">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
|
|
</div>
|
|
<div className="flex flex-col overflow-hidden">
|
|
<span className="text-sm font-bold text-text truncate">{fileMeta.name}</span>
|
|
<span className="text-xs text-muted">{formatBytes(fileMeta.size)} • Click to show in folder</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
})()}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col w-full min-w-0">
|
|
<DecryptedMessage msg={msg} liveDecryption={liveDecryption} animationTrigger={animationTrigger} components={markdownComponents} />
|
|
</div>
|
|
)}
|
|
{msg.edited && !showCrypto && <span className="text-[10px] text-muted ml-2">(edited)</span>}
|
|
|
|
{msg.reactions && Object.keys(msg.reactions).length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
{Object.entries(msg.reactions).map(([emoji, users]) => (
|
|
<button
|
|
key={emoji}
|
|
onClick={() => {
|
|
if (canReact) network.sendReaction(msg.id, emoji, isDMView && !isGroupChat, activeChannel);
|
|
}}
|
|
disabled={!canReact}
|
|
className={`px-1.5 py-0.5 rounded text-xs flex items-center gap-1 border ${users.includes(myKey) ? 'bg-accent/20 border-accent text-accent' : 'bg-surface border-panel text-muted hover:border-muted'} ${!canReact ? 'cursor-not-allowed opacity-80' : ''}`}
|
|
>
|
|
<span>{emoji}</span>
|
|
<span className="font-bold">{users.length}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className={`absolute right-4 -top-3 flex flex-col items-end ${fullEmojiPicker === msg.id ? 'opacity-100 z-50' : (fullEmojiPicker ? 'opacity-0 pointer-events-none z-10' : 'opacity-0 group-hover/msg:opacity-100 z-10')}`}>
|
|
<div className="flex items-center bg-surface border border-panel rounded-md shadow-sm overflow-hidden">
|
|
{canReact && QUICK_EMOJIS.map(emoji => (
|
|
<button
|
|
key={emoji}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
network.sendReaction(msg.id, emoji, isDMView && !isGroupChat, activeChannel);
|
|
}}
|
|
className="hover:bg-panel p-1.5 transition-colors text-base"
|
|
title="React"
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
{canReact && (
|
|
<div className="relative">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setFullEmojiPicker(fullEmojiPicker === msg.id ? null : msg.id);
|
|
setEmojiPickerDirection(e.clientY > window.innerHeight / 2 ? 'up' : 'down');
|
|
}}
|
|
className="text-muted hover:text-text hover:bg-panel p-2 transition-colors"
|
|
title="Add Reaction"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M8 14s1.5 2 4 2 4-2 4-2"></path><line x1="9" y1="9" x2="9.01" y2="9"></line><line x1="15" y1="9" x2="15.01" y2="9"></line></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
{canPost && (
|
|
<button onClick={(e) => { e.stopPropagation(); setReplyingTo(msg); textareaRef.current?.focus(); }} className="text-muted hover:text-text hover:bg-panel p-2 transition-colors" title="Reply">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg>
|
|
</button>
|
|
)}
|
|
{isMe && msg.payload?.type !== 'server_invite' && (
|
|
<button onClick={(e) => { e.stopPropagation(); startEditing(msg); }} className="text-muted hover:text-text hover:bg-panel p-2 transition-colors" title="Edit">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
|
</button>
|
|
)}
|
|
{(canDelete || isPlatformAdmin || isMe) && (
|
|
<button onClick={(e) => { e.stopPropagation(); network.sendDeleteMessage(msg.id); }} className="text-red-500 hover:text-red-400 hover:bg-panel p-2 transition-colors" title="Delete">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{fullEmojiPicker === msg.id && (
|
|
<div className={`absolute right-0 ${emojiPickerDirection === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'} bg-panel border border-surface rounded shadow-xl p-2 w-64 h-48 overflow-y-auto custom-scrollbar z-50 grid grid-cols-6 gap-1`} onClick={e => e.stopPropagation()}>
|
|
{ALL_EMOJIS.map(emoji => (
|
|
<button
|
|
key={emoji}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
network.sendReaction(msg.id, emoji, isDMView && !isGroupChat, activeChannel);
|
|
setFullEmojiPicker(null);
|
|
}}
|
|
className="hover:bg-surface rounded p-1 text-lg transition-colors"
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
<div className="px-4 pb-4 pt-1 shrink-0 relative">
|
|
<div className="absolute -top-6 left-4 text-xs font-medium text-muted flex items-center gap-1.5 h-6">
|
|
{typingText && (
|
|
<><span className="flex gap-1 items-center mr-1"><span className="w-1.5 h-1.5 rounded-full typing-dot" style={{ animationDelay: '0s' }}></span><span className="w-1.5 h-1.5 rounded-full typing-dot" style={{ animationDelay: '0.15s' }}></span><span className="w-1.5 h-1.5 rounded-full typing-dot" style={{ animationDelay: '0.3s' }}></span></span>{typingText}</>
|
|
)}
|
|
</div>
|
|
|
|
{mentionQuery !== null && filteredMentions.length > 0 && (
|
|
<div className="absolute bottom-full left-4 mb-2 w-64 bg-panel border border-surface rounded-lg shadow-xl overflow-hidden z-50">
|
|
<div className="bg-surface px-3 py-1.5 text-xs font-bold text-muted uppercase border-b border-panel">
|
|
{mentionType === 'channel' ? 'Channels' : 'Members'}
|
|
</div>
|
|
<div className="max-h-48 overflow-y-auto custom-scrollbar">
|
|
{filteredMentions.map((item, i) => (
|
|
<div
|
|
key={item.isChannel ? item.name : item.username}
|
|
onClick={() => insertMention(item)}
|
|
className={`flex items-center gap-2 px-3 py-2 cursor-pointer ${i === mentionIndex ? 'bg-accent/20' : 'hover:bg-surface'}`}
|
|
>
|
|
{item.isChannel ? (
|
|
<>
|
|
<div className="w-6 h-6 rounded-full bg-base flex items-center justify-center text-muted font-bold text-xs">
|
|
{item.type === 'text' ? '#' : '🔊'}
|
|
</div>
|
|
<div className="flex flex-col overflow-hidden">
|
|
<span className="text-sm font-bold text-text truncate">{item.name}</span>
|
|
<span className="text-[10px] text-muted truncate">{item.type === 'text' ? 'Text Channel' : 'Voice Channel'}</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{item.isSpecial ? (
|
|
<div className="w-6 h-6 rounded-full bg-accent flex items-center justify-center text-white font-bold text-xs">@</div>
|
|
) : (
|
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-white text-[10px] font-bold overflow-hidden ${item.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
|
|
{item.avatar ? <img src={item.avatar} className="w-full h-full object-cover" /> : item.displayName.substring(0, 2).toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col overflow-hidden">
|
|
<span className="text-sm font-bold text-text truncate">{item.displayName}</span>
|
|
<span className="text-[10px] text-muted truncate">@{item.username}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{replyingTo && (
|
|
<div className="bg-surface px-4 py-2 rounded-t-lg border-b border-base flex items-center justify-between text-sm text-muted">
|
|
<div className="flex items-center gap-2 truncate">
|
|
<span className="font-bold">Replying to @{replyingTo.senderName}</span>
|
|
<span className="truncate max-w-md">{replyingTo.text || 'Attachment'}</span>
|
|
</div>
|
|
<button onClick={() => setReplyingTo(null)} className="hover:text-text">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{attachments.length > 0 && (
|
|
<div className={`bg-surface p-4 border-b border-base flex gap-4 overflow-x-auto custom-scrollbar mx-0 ${replyingTo ? '' : 'rounded-t-lg mt-2'}`}>
|
|
{attachments.map((att, i) => (
|
|
<div key={i} className="relative w-40 h-40 bg-base rounded-lg border border-panel flex flex-col items-center justify-center shrink-0 group">
|
|
<button onClick={() => setAttachments(prev => prev.filter((_, index) => index !== i))} className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-10">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
{att.preview ? (
|
|
<img src={att.preview} className="w-full h-full object-cover rounded-lg" />
|
|
) : (
|
|
<div className="text-muted flex flex-col items-center gap-2 p-2 text-center">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
|
|
<span className="text-xs font-bold truncate w-full">{att.name}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className={`bg-surface p-3 flex items-center gap-3 relative ${(attachments.length > 0 || replyingTo) ? 'rounded-b-lg' : 'rounded-lg'}`}>
|
|
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={(e) => processFiles(e.target.files)} />
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="w-6 h-6 rounded-full bg-muted hover:bg-text text-base flex items-center justify-center shrink-0 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
disabled={!canPost || !canUpload || !myKey}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
|
</button>
|
|
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={inputText}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
placeholder={canPost ? `Message ${isGroupChat ? '' : (isDMView ? '@' : '#')}${headerName}` : "You do not have permission to post in this room"}
|
|
className="bg-transparent border-none outline-none text-text w-full disabled:opacity-50 resize-none max-h-[50vh] custom-scrollbar"
|
|
disabled={!canPost || !myKey}
|
|
rows={1}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |