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 ? (
{children}
)
},
a: ({node, href, children, ...props}) => {
if (href && href.startsWith('mention://')) {
return {children};
}
if (href && href.startsWith('channel://')) {
return {children};
}
return {children};
},
p: ({node, children, ...props}) =>
{children}
}; 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
{children}
)
},
a: ({node, href, children, ...props}) => {
if (href && href.startsWith('mention://')) {
return (
{
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}
);
}
if (href && href.startsWith('channel://')) {
return (
{
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}
);
}
return {children};
},
p: ({node, children, ...props}) => {children}
}), [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 ({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.`)}