// Team Chat โ€” full Slack-style messaging system (() => { const { Avatar, Badge } = window.TechyFuelOSDesignSystem_be0222; const EMOJIS = ['๐Ÿ‘','โค๏ธ','๐Ÿ˜‚','๐ŸŽ‰','๐Ÿ”ฅ','๐Ÿ˜ฎ','๐Ÿ‘€','โœ…','๐Ÿ’ฏ','๐Ÿš€','๐Ÿ˜ข','๐Ÿ‘']; function fmtTime(ts) { if (!ts) return ''; const d = new Date(ts); const now = new Date(); const isToday = d.toDateString() === now.toDateString(); const isYesterday = new Date(now - 86400000).toDateString() === d.toDateString(); const time = d.toLocaleTimeString('en', { hour: 'numeric', minute: '2-digit', hour12: true }); if (isToday) return time; if (isYesterday) return 'Yesterday ' + time; return d.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + ' ' + time; } function fmtDate(ts) { if (!ts) return ''; const d = new Date(ts); const now = new Date(); if (d.toDateString() === now.toDateString()) return 'Today'; if (new Date(now - 86400000).toDateString() === d.toDateString()) return 'Yesterday'; return d.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric' }); } function fmtSize(bytes) { if (!bytes) return ''; if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'; return (bytes / 1024).toFixed(0) + ' KB'; } function initials(name) { if (!name) return '?'; return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); } function getChannelDmName(ch, team, myId) { if (ch.type !== 'dm') return ch.name; const other = team.find(m => m.id !== myId && ch.name.includes(m.id)); return other ? other.name : ch.name; } // โ”€โ”€ Avatar with initials fallback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function MemberAvatar({ name, size = 28 }) { return (
{initials(name)}
); } // โ”€โ”€ Emoji reaction pill โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function ReactionPill({ emoji, count, mine, onClick }) { return ( ); } // โ”€โ”€ Emoji picker popup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function EmojiPicker({ onPick, onClose }) { return (
e.stopPropagation()}> {EMOJIS.map(e => ( ))}
); } // โ”€โ”€ Message bubble โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function MessageBubble({ msg, myId, onReact, onThread, onPin, onDelete, showDateDivider, isThread, isDM, otherReadAt, isGroup, chMembers }) { const [hover, setHover] = React.useState(false); const [emojiOpen, setEmojiOpen] = React.useState(false); useLucide(); const senderName = msg.team_members?.name || 'Unknown'; const reactionMap = {}; (msg.reactions || []).forEach(r => { if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, mine: false }; reactionMap[r.emoji].count++; if (r.member_id === myId) reactionMap[r.emoji].mine = true; }); const isMe = msg.sender_id === myId; const renderedContent = React.useMemo(() => { if (!msg.content) return null; return msg.content.replace(/@(\w[\w\s]*?)(?=\s|$|[^a-zA-Z])/g, '@$1'); }, [msg.content]); return ( <> {showDateDivider && (
{fmtDate(msg.created_at)}
)}
setHover(true)} onMouseLeave={() => { setHover(false); setEmojiOpen(false); }} style={{ position: 'relative', display: 'flex', flexDirection: isMe ? 'row-reverse' : 'row', gap: 10, padding: '4px 16px', background: hover ? 'var(--slate-50)' : 'transparent', transition: 'background 0.1s' }} >
{isMe ? 'You' : senderName} {msg.pinned && } {fmtTime(msg.created_at)} {isMe && isDM && (() => { const seen = otherReadAt && new Date(otherReadAt) >= new Date(msg.created_at); return ; })()} {isMe && isGroup && !isThread && (() => { // WhatsApp group style: count other members whose last_read_at is at // or after this message's time, and list their names in the tooltip. const msgT = new Date(msg.created_at); const seenBy = (chMembers || []).filter(cm => cm.member_id !== myId && cm.last_read_at && new Date(cm.last_read_at) >= msgT); const names = seenBy.map(cm => cm.team_members?.name || cm.name || 'Member'); const anySeen = seenBy.length > 0; return ( {anySeen && {seenBy.length}} ); })()}
{msg.content && (
)} {/* Voice message โ†’ inline audio player */} {msg.file_url && msg.file_type?.startsWith('audio') && (
)} {/* Video โ†’ inline player */} {msg.file_url && msg.file_type?.startsWith('video') && (
{hover && (
{emojiOpen &&
onReact(msg.id, e)} onClose={() => setEmojiOpen(false)} />
}
{!isThread && ( )} {isMe && ( )}
)}
); } // โ”€โ”€ Message input โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function MessageInput({ channelName, onSend, team, myId, placeholder }) { useLucide(); const [text, setText] = React.useState(''); const [mentionQ, setMentionQ] = React.useState(''); const [mentionOpen, setMentionOpen] = React.useState(false); const [dragging, setDragging] = React.useState(false); const [uploading, setUploading] = React.useState(false); const inputRef = React.useRef(); const fileRef = React.useRef(); function handleInput(e) { const val = e.target.value; setText(val); const match = val.match(/@([\w\s]*)$/); if (match) { setMentionQ(match[1]); setMentionOpen(true); } else setMentionOpen(false); } function pickMention(member) { const newText = text.replace(/@([\w\s]*)$/, `@${member.name} `); setText(newText); setMentionOpen(false); inputRef.current?.focus(); } function handleKeyDown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } if (e.key === 'Escape') setMentionOpen(false); } function submit() { const trimmed = text.trim(); if (!trimmed) return; onSend({ content: trimmed }); setText(''); setMentionOpen(false); } async function handleFiles(files) { if (!files.length || !window.API) return; setUploading(true); for (const file of files) { try { const path = `chat/${Date.now()}_${file.name.replace(/\s+/g, '_')}`; // uploadFile returns the stored file RECORD ({ id, url, ... }), not a // bare URL string. The previous code put that whole object into // file_url, so attachments rendered as "[object Object]" and looked // broken โ€” this is the "file attach doesn't work" report. Pull the real // public url off the record. const rec = await window.API.uploadFile('files', path, file); const fileUrl = rec && (rec.url || rec.file_url); if (!fileUrl) throw new Error('Upload did not return a file URL'); onSend({ file_url: fileUrl, file_name: file.name, file_size: file.size, file_type: file.type }); } catch (e) { alert('Could not attach "' + file.name + '": ' + (e && e.message ? e.message : 'upload failed') + '\nPlease try again.'); } } setUploading(false); } // โ”€โ”€ Voice messages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const [recording, setRecording] = React.useState(false); const [recSecs, setRecSecs] = React.useState(0); const recorderRef = React.useRef(null); const chunksRef = React.useRef([]); const recTimerRef = React.useRef(null); const cancelledRef = React.useRef(false); async function startRecording() { // getUserMedia only exists in a secure context (https) โ€” if it's missing, // that's almost always the reason no permission prompt shows. if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert('Voice recording needs a secure (https) connection and a supported browser. If you added this app to your home screen, open it in Chrome/Safari instead, or check the site is opened over https.'); return; } if (!window.MediaRecorder) { alert('Voice recording is not supported on this browser. Please update your browser.'); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mime = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : (MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : ''); const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); chunksRef.current = []; rec.ondataavailable = e => { if (e.data.size) chunksRef.current.push(e.data); }; rec.onstop = async () => { stream.getTracks().forEach(t => t.stop()); clearInterval(recTimerRef.current); // Explicit cancel flag โ€” do NOT rely on blob size. ondataavailable can // deliver a final chunk during stop() even after we clear the array, so // a "cancelled" recording could still be non-empty and get sent. if (cancelledRef.current) { chunksRef.current = []; setRecording(false); setRecSecs(0); return; } const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' }); if (blob.size < 800) { setRecording(false); setRecSecs(0); return; } // too short setUploading(true); try { const ext = (rec.mimeType || '').includes('mp4') ? 'm4a' : 'webm'; const file = new File([blob], `voice_${Date.now()}.${ext}`, { type: blob.type }); const recd = await window.API.uploadFile('files', `chat/voice_${Date.now()}.${ext}`, file); const url = recd && (recd.url || recd.file_url); if (url) onSend({ file_url: url, file_name: 'Voice message', file_size: file.size, file_type: 'audio/' + ext }); } catch (e) { alert('Could not send voice message. Please try again.'); } setUploading(false); setRecording(false); setRecSecs(0); }; recorderRef.current = rec; cancelledRef.current = false; rec.start(); setRecording(true); setRecSecs(0); recTimerRef.current = setInterval(() => setRecSecs(s => s + 1), 1000); } catch (e) { // Tell the user EXACTLY why so they can fix it โ€” a silent failure is why // it felt like "no popup came". const name = e && e.name; if (name === 'NotAllowedError' || name === 'SecurityError') { alert('Microphone access is blocked. Tap the lock/๐Ÿ”’ icon in the address bar โ†’ Permissions โ†’ allow Microphone, then try again. On mobile, also check the browser has mic permission in your phone Settings โ†’ Apps.'); } else if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { alert('No microphone was found on this device.'); } else if (name === 'NotReadableError') { alert('Your microphone is in use by another app. Close it and try again.'); } else { alert('Could not start recording: ' + (e && e.message ? e.message : 'microphone permission needed') + '. Please allow microphone access and try again.'); } } } function stopRecording(send) { if (!recorderRef.current) return; cancelledRef.current = !send; // trash button โ†’ cancel, never send if (!send) chunksRef.current = []; try { recorderRef.current.stop(); } catch {} } const filteredMentions = mentionOpen ? team.filter(m => m.id !== myId && m.name.toLowerCase().includes(mentionQ.toLowerCase())).slice(0, 5) : []; return (
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(Array.from(e.dataTransfer.files)); }} > {mentionOpen && filteredMentions.length > 0 && (
Team members
{filteredMentions.map(m => ( ))}
)}
{dragging && (
Drop file to share
)} {!dragging && ( <>