// 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}{count}
);
}
// โโ Emoji picker popup โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function EmojiPicker({ onPick, onClose }) {
return (
e.stopPropagation()}>
{EMOJIS.map(e => (
{ onPick(e); onClose(); }} style={{ fontSize: 20, background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6, padding: '2px 4px', lineHeight: 1 }}>{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') && (
)}
{/* Image โ inline preview */}
{msg.file_url && msg.file_type?.startsWith('image') && (
)}
{/* Other files (not audio/image/video) โ download chip */}
{msg.file_url && !msg.file_type?.startsWith('audio') && !msg.file_type?.startsWith('image') && !msg.file_type?.startsWith('video') && (
{msg.file_name}
{msg.file_size &&
{fmtSize(msg.file_size)}
}
)}
{Object.keys(reactionMap).length > 0 && (
{Object.entries(reactionMap).map(([emoji, { count, mine }]) => (
onReact(msg.id, emoji)} />
))}
)}
{msg.reply_count > 0 && !isThread && (
onThread(msg)} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, marginTop: 5, padding: '3px 8px', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--blue-600)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', fontFamily: 'var(--font-sans)', borderRadius: 'var(--radius-sm)' }}>
{msg.reply_count} {msg.reply_count === 1 ? 'reply' : 'replies'}
)}
{hover && (
setEmojiOpen(o => !o)} title="React" style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', borderRadius: 'var(--radius-sm)', color: 'var(--text-muted)', fontSize: 16 }}>๐
{emojiOpen &&
onReact(msg.id, e)} onClose={() => setEmojiOpen(false)} />
}
{!isThread && (
onThread(msg)} title="Reply in thread" style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', borderRadius: 'var(--radius-sm)', color: 'var(--text-muted)' }}>
)}
onPin(msg)} title={msg.pinned ? 'Unpin' : 'Pin message'} style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', borderRadius: 'var(--radius-sm)', color: msg.pinned ? 'var(--amber-500)' : 'var(--text-muted)' }}>
{isMe && (
onDelete(msg.id)} title="Delete message" style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', borderRadius: 'var(--radius-sm)', color: 'var(--text-muted)' }}>
)}
)}
>
);
}
// โโ 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 => (
pickMention(m)} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '9px 12px', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', color: 'var(--text-body)', textAlign: 'left' }}>
{m.name}
{m.role || ''}
))}
pickMention({ name: 'here' })} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '9px 12px', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', color: 'var(--text-body)', textAlign: 'left', borderTop: '1px solid var(--border-subtle)' }}>
@here
Notify all channel members
)}
{dragging && (
Drop file to share
)}
{!dragging && (
<>
fileRef.current?.click()} title="Attach file" style={{ width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', borderRadius: 'var(--radius-sm)' }}>
{uploading ? : }
handleFiles(Array.from(e.target.files || []))} />
{ const newText = text + '@'; setText(newText); setMentionOpen(true); setMentionQ(''); inputRef.current?.focus(); }} title="Mention someone" style={{ width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', borderRadius: 'var(--radius-sm)', fontSize: 14, fontWeight: 700 }}>@
{!recording && (
)}
{recording ? (
{String(Math.floor(recSecs/60)).padStart(2,'0')}:{String(recSecs%60).padStart(2,'0')}
stopRecording(false)} title="Cancel" style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--slate-100)', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', color: 'var(--text-muted)' }}>
stopRecording(true)} title="Send voice message" style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--blue-600)', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', color: '#fff' }}>
) : (
)}
>
)}
);
}
// โโ Sidebar item โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function SidebarAddBtn({ onClick, title }) {
const [h, setH] = React.useState(false);
return (
setH(true)} onMouseLeave={() => setH(false)}
style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', background: h ? 'rgba(255,255,255,0.15)' : 'none', border: 'none', borderRadius: 'var(--radius-sm)', cursor: 'pointer', color: 'rgba(255,255,255,0.6)', transition: 'background 0.12s' }}>
);
}
function SidebarCh({ ch, active, unread, onClick }) {
const [h, setH] = React.useState(false);
return (
setH(true)} onMouseLeave={() => setH(false)}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: unread ? 700 : active ? 600 : 500, background: active ? 'rgba(255,255,255,0.15)' : h ? 'rgba(255,255,255,0.08)' : 'transparent', color: active ? '#fff' : unread ? 'rgba(255,255,255,0.95)' : 'rgba(255,255,255,0.7)', textAlign: 'left', transition: 'all 0.12s' }}
>
{ch.type === 'dm' ? (
) : (
)}
{ch.displayName || ch.name}
{unread > 0 && {unread} }
);
}
// โโ New channel / DM modal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function NewChannelModal({ open, onClose, team, myId, onCreate, type }) {
useLucide();
const [name, setName] = React.useState('');
const [desc, setDesc] = React.useState('');
const [selectedMembers, setSelectedMembers] = React.useState([]);
const [saving, setSaving] = React.useState(false);
if (!open) return null;
const isDM = type === 'dm';
const isGroup = type === 'group';
const otherMembers = team.filter(m => m.id !== myId);
async function handleCreate() {
if (isDM && selectedMembers.length === 0) return;
if ((isGroup || type === 'channel') && !name.trim()) return;
setSaving(true);
try {
let chName = name.trim();
if (isDM) chName = [myId, selectedMembers[0]].sort().join('_');
if (isGroup) chName = name.trim();
await onCreate({ name: chName, type, description: desc, members: isDM ? [myId, ...selectedMembers] : (selectedMembers.length ? [myId, ...selectedMembers] : [myId]) });
setName(''); setDesc(''); setSelectedMembers([]);
onClose();
} finally { setSaving(false); }
}
return (
e.stopPropagation()}>
{isDM ? 'New direct message' : isGroup ? 'New group chat' : 'New channel'}
{!isDM && (
<>
{isGroup ? 'Group name' : 'Channel name'}
setName(e.target.value.toLowerCase().replace(/\s+/g, '-'))} placeholder={isGroup ? 'design-team' : 'channel-name'} style={{ width: '100%', height: 38, padding: '0 12px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', boxSizing: 'border-box', outline: 'none' }} />
{!isGroup && (
Description
setDesc(e.target.value)} placeholder="What's this channel for?" style={{ width: '100%', height: 38, padding: '0 12px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', boxSizing: 'border-box', outline: 'none' }} />
)}
>
)}
{isDM ? 'Send to' : 'Add members'}
{otherMembers.map(m => {
const checked = selectedMembers.includes(m.id);
return (
setSelectedMembers(prev => isDM ? [m.id] : checked ? prev.filter(id => id !== m.id) : [...prev, m.id])}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', border: `1px solid ${checked ? 'var(--blue-400)' : 'var(--border-subtle)'}`, borderRadius: 'var(--radius-md)', background: checked ? 'var(--blue-50)' : 'transparent', cursor: 'pointer', fontFamily: 'var(--font-sans)' }}>
{checked && }
);
})}
Cancel
{saving ? 'Creatingโฆ' : isDM ? 'Open DM' : 'Create'}
);
}
// โโ Search panel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function SearchPanel({ open, onClose, onJump }) {
useLucide();
const [q, setQ] = React.useState('');
const [results, setResults] = React.useState([]);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (!q.trim() || !window.API) { setResults([]); return; }
const timer = setTimeout(async () => {
setLoading(true);
try {
const r = await window.API.searchMessages(q.trim());
if (r.data) setResults(r.data);
} catch {}
setLoading(false);
}, 300);
return () => clearTimeout(timer);
}, [q]);
if (!open) return null;
return (
e.stopPropagation()}>
setQ(e.target.value)} placeholder="Search messages, files, peopleโฆ" style={{ flex: 1, border: 'none', outline: 'none', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-base)', color: 'var(--text-body)' }} />
{loading &&
Searchingโฆ
}
{!loading && q.trim() && results.length === 0 &&
No results for "{q}"
}
{results.map(msg => (
{ onJump(msg); onClose(); }} style={{ display: 'flex', gap: 12, width: '100%', padding: '12px 18px', border: 'none', borderBottom: '1px solid var(--border-subtle)', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'var(--font-sans)' }}>
{msg.team_members?.name}
in #{msg.channels?.name}
{fmtTime(msg.created_at)}
{msg.content}
))}
);
}
// โโ Main TeamChat component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function TeamChat() {
useLucide();
const [channels, setChannels] = React.useState([]);
const [activeId, setActiveId] = React.useState(null);
const [messages, setMessages] = React.useState([]);
const [team, setTeam] = React.useState([]);
const [myId, setMyId] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [msgLoading, setMsgLoading] = React.useState(false);
const [thread, setThread] = React.useState(null); // { parent, replies }
const [threadLoading, setThreadLoading] = React.useState(false);
const [pinnedOpen, setPinnedOpen] = React.useState(false);
const [pinned, setPinned] = React.useState([]);
const [searchOpen, setSearchOpen] = React.useState(false);
const [newModal, setNewModal] = React.useState(null); // 'channel' | 'dm' | 'group'
const [unread, setUnread] = React.useState({});
const [membersOpen, setMembersOpen] = React.useState(false);
const [channelMembers, setChannelMembers] = React.useState([]);
const [call, setCall] = React.useState(null); // { channelId, channelName, type: 'audio'|'video' }
const [otherReadAt, setOtherReadAt] = React.useState(null); // DM partner's last_read_at, for "Seen"
const [chMembers, setChMembers] = React.useState([]); // all members of active channel w/ last_read_at, for group "Seen by"
const subRef = React.useRef(null);
const bottomRef = React.useRef(null);
const savedMyId = React.useRef(null);
// Mobile: the 240px channel list + message pane can't sit side-by-side on a
// phone (they squeeze together โ the "chat tab won't close" report). Track a
// narrow viewport and show EITHER the list OR the open conversation, with a
// back arrow, like every mobile chat app.
const [isMobile, setIsMobile] = React.useState(() => (typeof window !== 'undefined' ? window.innerWidth <= 768 : false));
React.useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth <= 768);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// When the user answers an incoming call from the global banner (AppShell), it
// navigates here and fires 'tf-answer-call' / leaves tf_pending_call. Open the
// call modal on that channel so they join the same room the caller started.
React.useEffect(() => {
function openPending(detail) {
if (!detail || !detail.channelId) return;
setActiveId(detail.channelId);
setCall({ channelId: detail.channelId, channelName: detail.channelName || 'Team', type: detail.type || 'audio' });
}
function onAnswer(e) { openPending(e.detail); }
window.addEventListener('tf-answer-call', onAnswer);
// Also check a pending call left in storage (covers the navigate happening
// before this listener mounted).
try {
const raw = localStorage.getItem('tf_pending_call');
if (raw) { localStorage.removeItem('tf_pending_call'); openPending(JSON.parse(raw)); }
} catch {}
// Opening a chat notification: jump to that channel (works after a
// workspace switch+reload too, via the stored id).
function openChannel(id) { if (id) setActiveId(id); }
function onOpenChannel(e) { openChannel(e.detail && e.detail.channelId); }
window.addEventListener('tf-open-channel', onOpenChannel);
try {
const chId = localStorage.getItem('tf_open_channel');
if (chId) { localStorage.removeItem('tf_open_channel'); openChannel(chId); }
} catch {}
return () => {
window.removeEventListener('tf-answer-call', onAnswer);
window.removeEventListener('tf-open-channel', onOpenChannel);
};
}, []);
const activeChannel = channels.find(c => c.id === activeId);
// Load initial data
React.useEffect(() => {
if (!window.API) { setLoading(false); return; }
(async () => {
try {
const [tr, cr] = await Promise.all([window.API.getTeam(), window.API.getChannels()]);
const members = tr.data || [];
const chs = cr.data || [];
setTeam(members);
// Chat identity is always the real signed-in user โ never a
// manual picker. Falls back to the first team member only when
// there's no resolved auth identity (e.g. local/demo preview).
const me = members.find(m => m.id === window.TFMyMemberId) || members[0];
if (me) { setMyId(me.id); savedMyId.current = me.id; }
// Enrich DM channel display names. The DM name is "memberIdA_memberIdB";
// resolve the OTHER member to their real name. If they're not in this
// workspace's team list (cross-workspace, or not loaded) never fall back
// to the raw "uuid_uuid" string โ that's the "DM shows a UUID" bug โ
// use a friendly placeholder instead.
const enriched = chs.map(ch => {
if (ch.type === 'dm') {
const ids = String(ch.name || '').split('_');
const otherId = ids.find(id => id !== me?.id);
const other = members.find(m => m.id === otherId);
const looksLikeRawName = /^[0-9a-f-]{20,}_[0-9a-f-]{20,}$/i.test(ch.name || '');
return { ...ch, displayName: other?.name || (looksLikeRawName ? 'Direct message' : ch.name) };
}
return ch;
});
setChannels(enriched);
// On desktop auto-open the first channel; on mobile leave the list
// showing so the user picks one (auto-opening would hide the list).
if (enriched.length > 0 && !(typeof window !== 'undefined' && window.innerWidth <= 768)) setActiveId(enriched[0].id);
// Slack-style per-channel unread badges: seed them from the unread chat
// notifications (each carries the channel in link_id) so a channel with
// unseen messages shows a count even before you open it โ previously the
// count only tracked the channel you were already viewing.
try {
if (window.API && window.API.getNotifications) {
const { data: notifs } = await window.API.getNotifications(me?.id || undefined, 50);
if (Array.isArray(notifs)) {
const counts = {};
notifs.filter(n => n.type === 'chat' && !n.read && n.link_id)
.forEach(n => { counts[n.link_id] = (counts[n.link_id] || 0) + 1; });
if (Object.keys(counts).length) setUnread(prev => ({ ...counts, ...prev }));
}
}
} catch {}
} catch {}
setLoading(false);
})();
}, []);
// Load messages when channel changes
React.useEffect(() => {
if (!activeId || !window.API) return;
setMsgLoading(true);
setThread(null);
setMessages([]);
setOtherReadAt(null);
// Unsubscribe previous
if (subRef.current) { try { subRef.current.unsubscribe(); } catch {} }
(async () => {
try {
const r = await window.API.getMessages(activeId);
if (r.data) {
const msgs = await enrichWithReactions(r.data);
setMessages(msgs);
}
} catch {}
setMsgLoading(false);
refreshReadStatus(activeId);
if (myId) { try { await window.API.markChannelRead(activeId, myId); } catch {} }
// Realtime subscription
try {
subRef.current = window.API.subscribeToMessages(activeId, async (newMsg) => {
if (newMsg.thread_parent_id) {
// Update reply count on parent
setMessages(prev => prev.map(m => m.id === newMsg.thread_parent_id ? { ...m, reply_count: (m.reply_count || 0) + 1 } : m));
// If thread is open for this parent, add the reply
setThread(prev => {
if (prev && prev.parent.id === newMsg.thread_parent_id) {
const fullMsg = { ...newMsg };
return { ...prev, replies: [...prev.replies, fullMsg] };
}
return prev;
});
} else {
const enriched = await enrichWithReactions([newMsg]);
setMessages(prev => {
if (prev.find(m => m.id === newMsg.id)) return prev;
return [...prev, ...enriched];
});
// Mark as unread if not our message
if (newMsg.sender_id !== savedMyId.current) {
setUnread(prev => ({ ...prev, [activeId]: (prev[activeId] || 0) + 1 }));
// The channel is open right now โ mark it read immediately.
if (myId) { try { await window.API.markChannelRead(activeId, myId); } catch {} }
}
}
});
} catch {}
})();
// Clear unread for this channel
setUnread(prev => ({ ...prev, [activeId]: 0 }));
// Poll the DM partner's read state so "Seen" appears without a reload.
const readPoll = setInterval(() => refreshReadStatus(activeId), 8000);
return () => clearInterval(readPoll);
}, [activeId, myId]);
async function refreshReadStatus(channelId) {
if (!window.API || !myId) return;
try {
const r = await window.API.getChannelMembers(channelId);
const all = r.data || [];
setChMembers(all);
const other = all.find(cm => cm.member_id !== myId);
setOtherReadAt(other ? other.last_read_at : null);
} catch {}
}
// Auto-scroll to bottom
React.useEffect(() => {
if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
async function enrichWithReactions(msgs) {
return Promise.all(msgs.map(async msg => {
try {
const r = await window.API.getReactions(msg.id);
return { ...msg, reactions: r.data || [] };
} catch { return { ...msg, reactions: [] }; }
}));
}
async function handleSend(payload) {
if (!activeId || !myId || !window.API) return;
try {
const { data } = await window.API.sendMessage({ channel_id: activeId, sender_id: myId, ...payload });
if (data) {
const enriched = { ...data, reactions: [] };
setMessages(prev => prev.find(m => m.id === data.id) ? prev : [...prev, enriched]);
}
} catch {}
}
async function handleThreadSend(payload) {
if (!thread || !myId || !window.API) return;
try {
const { data } = await window.API.sendMessage({ channel_id: activeId, sender_id: myId, thread_parent_id: thread.parent.id, ...payload });
if (data) {
setThread(prev => ({ ...prev, replies: [...prev.replies, { ...data, reactions: [] }] }));
setMessages(prev => prev.map(m => m.id === thread.parent.id ? { ...m, reply_count: (m.reply_count || 0) + 1 } : m));
}
} catch {}
}
async function handleReact(msgId, emoji) {
if (!myId || !window.API) return;
const msg = messages.find(m => m.id === msgId);
const existing = msg?.reactions?.find(r => r.member_id === myId && r.emoji === emoji);
try {
if (existing) {
await window.API.removeReaction(msgId, myId, emoji);
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: m.reactions.filter(r => !(r.member_id === myId && r.emoji === emoji)) } : m));
} else {
await window.API.addReaction({ message_id: msgId, member_id: myId, emoji });
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: [...(m.reactions || []), { message_id: msgId, member_id: myId, emoji }] } : m));
}
} catch {}
}
async function handlePin(msg) {
if (!window.API) return;
try {
await window.API.pinMessage(msg.id, !msg.pinned);
setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, pinned: !m.pinned } : m));
} catch {}
}
async function handleDelete(msgId) {
if (!window.API) return;
try {
await window.API.deleteMessage(msgId);
setMessages(prev => prev.filter(m => m.id !== msgId));
} catch {}
}
async function openThread(msg) {
setThread({ parent: msg, replies: [] });
setThreadLoading(true);
try {
const r = await window.API.getMessages(activeId, { parentId: msg.id });
const replies = await enrichWithReactions(r.data || []);
setThread({ parent: msg, replies });
// Count replies on parent
setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, reply_count: replies.length } : m));
} catch {}
setThreadLoading(false);
}
async function openPinned() {
setPinnedOpen(true);
if (!window.API) return;
try {
const r = await window.API.getPinnedMessages(activeId);
if (r.data) setPinned(r.data);
} catch {}
}
async function openMembers() {
setMembersOpen(true);
if (!window.API) return;
try {
const r = await window.API.getChannelMembers(activeId);
if (r.data) setChannelMembers(r.data);
} catch {}
}
async function handleCreateChannel({ name, type, description, members }) {
if (!window.API) return;
try {
const { data: ch } = await window.API.createChannel({ name, type, description, created_by: myId });
if (ch) {
// Add members
for (const memberId of members) {
try { await window.API.addChannelMember({ channel_id: ch.id, member_id: memberId }); } catch {}
}
const enriched = type === 'dm'
? { ...ch, displayName: team.find(m => members.find(id => id !== myId) === m.id)?.name || name }
: ch;
setChannels(prev => [...prev, enriched]);
setActiveId(ch.id);
}
} catch {}
}
// Group channels for sidebar
const publicChannels = channels.filter(c => c.type === 'channel');
const projectChannels = channels.filter(c => c.type === 'project');
const dms = channels.filter(c => c.type === 'dm');
const groups = channels.filter(c => c.type === 'group');
// Date divider logic
function showDivider(msgs, idx) {
if (idx === 0) return true;
const prev = new Date(msgs[idx - 1].created_at).toDateString();
const curr = new Date(msgs[idx].created_at).toDateString();
return prev !== curr;
}
// "Seen" indicator (DMs only) โ shows under the last message you sent
// once the other participant's last_read_at catches up to it.
const lastOwnMsg = activeChannel?.type === 'dm' ? [...messages].reverse().find(m => m.sender_id === myId) : null;
const seenByOther = !!(lastOwnMsg && otherReadAt && new Date(otherReadAt) >= new Date(lastOwnMsg.created_at));
if (loading) return Loading chatโฆ
;
return (
{/* โโ Chat Sidebar โโ (on mobile: full-width, and hidden once a
conversation is open so the message pane gets the whole screen) */}
{/* Header */}
Team Chat
setSearchOpen(true)} style={{ width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 'var(--radius-sm)', cursor: 'pointer', color: 'rgba(255,255,255,0.7)' }}>
{/* My identity โ always the real signed-in user, not switchable */}
m.id === myId)?.name || ''} size={22} />
{team.find(m => m.id === myId)?.name || 'You'}
{/* Channel list */}
{/* Channels */}
Channels
setNewModal('channel')} title="New channel" />
{publicChannels.map(ch =>
setActiveId(ch.id)} />)}
{/* Project channels */}
{projectChannels.length > 0 && (
Projects
{projectChannels.map(ch =>
setActiveId(ch.id)} />)}
)}
{/* Groups */}
{groups.length > 0 && (
Groups
setNewModal('group')} title="New group" />
{groups.map(ch =>
setActiveId(ch.id)} />)}
)}
{/* Direct messages */}
Direct messages
setNewModal('dm')} title="New direct message" />
{dms.length === 0 && (
setNewModal('dm')} style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', background: 'none', border: '1px dashed rgba(255,255,255,0.18)', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', color: 'rgba(255,255,255,0.4)', textAlign: 'left' }}>
Start a DM
)}
{dms.map(ch =>
setActiveId(ch.id)} />)}
{/* โโ Main message area โโ (on mobile: full-width, hidden until a
conversation is picked) */}
{/* Channel header */}
{activeChannel && (
{isMobile && (
setActiveId(null)} title="Back to conversations"
style={{ width: 34, height: 34, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', cursor: 'pointer', color: 'var(--text-body)' }}>
)}
{activeChannel.type === 'dm' ? (
) : (
)}
{activeChannel.displayName || activeChannel.name}
{activeChannel.description &&
{activeChannel.description}
}
setCall({ channelId: activeChannel.id, channelName: activeChannel.displayName || activeChannel.name, type: 'audio' })}
title="Voice call"
style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', cursor: 'pointer', color: 'var(--text-muted)' }}>
setCall({ channelId: activeChannel.id, channelName: activeChannel.displayName || activeChannel.name, type: 'video' })}
title="Video call"
style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', cursor: 'pointer', color: 'var(--text-muted)' }}>
setSearchOpen(true)} style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', cursor: 'pointer', color: 'var(--text-muted)' }}>
Pinned
)}
{/* Messages */}
{!loading && !activeChannel && (
No conversation selected
Pick a channel on the left, or start a new one.
)}
{msgLoading &&
Loading messagesโฆ
}
{!msgLoading && activeChannel && messages.length === 0 && (
{activeChannel?.type === 'dm' ? (
) : (
)}
{activeChannel?.type === 'dm' ? `Start a conversation with ${activeChannel.displayName}` : `Welcome to #${activeChannel?.name}`}
{activeChannel?.description || 'Send the first message below to get things going.'}
)}
{messages.map((msg, i) => (
))}
{/* WhatsApp-style: per-message ticks show sent/seen; a summary line
under the last own message confirms the exact "Seen at" time. */}
{lastOwnMsg && activeChannel?.type === 'dm' && seenByOther && (
Seen {fmtTime(otherReadAt)}
)}
{/* Input */}
{activeChannel && (
)}
{/* โโ Thread Panel โโ */}
{thread && (
Thread
setThread(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
{/* Parent message */}
{}} onPin={handlePin} onDelete={handleDelete} isThread showDateDivider={false} />
{threadLoading &&
Loadingโฆ
}
{!threadLoading && thread.replies.length === 0 && (
No replies yet
)}
{thread.replies.map((msg, i) => (
{}} onPin={handlePin} onDelete={handleDelete} isThread showDateDivider={showDivider(thread.replies, i)} />
))}
)}
{/* โโ Pinned messages panel โโ */}
{pinnedOpen && (
setPinnedOpen(false)}>
e.stopPropagation()}>
Pinned messages
setPinnedOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
{pinned.length === 0 &&
No pinned messages in this channel
}
{pinned.map(msg => (
{msg.team_members?.name}
{msg.content}
{fmtTime(msg.created_at)}
))}
)}
{/* โโ Channel members panel โโ */}
{membersOpen && (
setMembersOpen(false)}>
e.stopPropagation()}>
Members
setMembersOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
{channelMembers.length === 0 &&
No members found
}
{channelMembers.map(cm => (
{cm.team_members?.name}
{cm.team_members?.role || ''}
))}
{/* All team members if no channel_members data */}
{channelMembers.length === 0 && team.map(m => (
))}
)}
{/* โโ Search panel โโ */}
setSearchOpen(false)} onJump={msg => { setActiveId(msg.channel_id); }} />
{/* โโ New channel/DM modal โโ */}
setNewModal(null)} team={team} myId={myId} onCreate={handleCreateChannel} />
{/* โโ Call modal โโ */}
{call && setCall(null)} />}
);
}
// โโ Call Modal (Daily.co embed) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function CallModal({ call, myId, onClose }) {
useLucide();
const containerRef = React.useRef(null);
const frameRef = React.useRef(null);
const sessionIdRef = React.useRef(null);
const participantsRef = React.useRef(1);
const [ready, setReady] = React.useState(false);
const [error, setError] = React.useState(null);
const [duration, setDuration] = React.useState(0);
const [participants, setParticipants] = React.useState(1);
const roomName = 'techyfuel-os-' + call.channelId.replace(/-/g, '').slice(0, 20);
// Record the call session for the activity log
React.useEffect(() => {
if (!window.API) return;
window.API.startCallSession({
channel_id: call.channelId,
type: call.type,
room_name: roomName,
started_by: myId || null,
channelName: call.channelName,
}).then(r => { if (r.data) sessionIdRef.current = r.data.id; }).catch(() => {});
return () => {
if (sessionIdRef.current) window.API.endCallSession(sessionIdRef.current, participantsRef.current).catch(() => {});
};
}, []);
React.useEffect(() => {
let cancelled = false;
let joinTimer = null;
// Proactively ask for mic (and camera for video) BEFORE Jitsi loads. Jitsi
// in an iframe doesn't always surface the browser permission prompt on
// mobile, so the call would silently have no audio. Requesting here makes
// the browser show its Allow/Block prompt up front. Best-effort โ if the
// user blocks or it fails, we still let Jitsi try.
try {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices.getUserMedia({ audio: true, video: call.type === 'video' })
.then(stream => { try { stream.getTracks().forEach(t => t.stop()); } catch {} }) // release; Jitsi opens its own
.catch(() => {}); // blocked/denied โ Jitsi will show its own message
}
} catch {}
function join() {
if (cancelled || !containerRef.current || !window.JitsiMeetExternalAPI) return;
// Free Jitsi Meet room โ public, no API key. roomName is deterministic
// per channel, so both callers land in the SAME room.
const api = new window.JitsiMeetExternalAPI('meet.jit.si', {
roomName: roomName,
parentNode: containerRef.current,
width: '100%',
height: '100%',
userInfo: { displayName: localStorage.getItem('tf_my_name') || 'Team Member' },
configOverwrite: {
startWithVideoMuted: call.type === 'audio',
startWithAudioMuted: false,
// Join straight into the call inside our web app โ no prejoin gate,
// and never bounce the user to the Jitsi mobile app.
prejoinPageEnabled: false,
prejoinConfig: { enabled: false },
disableDeepLinking: true, // kills the "Open in the Jitsi Meet app" screen
disable1On1Mode: false,
},
interfaceConfigOverwrite: {
MOBILE_APP_PROMO: false, // no "download the app" banner
SHOW_JITSI_WATERMARK: false,
SHOW_PROMOTIONAL_CLOSE_PAGE: false,
HIDE_DEEP_LINKING_LOGO: true,
},
});
frameRef.current = api;
// As soon as Jitsi's iframe paints its own UI (prejoin or conference),
// drop our overlay so the user interacts with Jitsi directly.
api.addListener('videoConferenceJoined', () => { setReady(true); clearTimeout(joinTimer); });
api.addListener('participantJoined', () => setParticipants(p => { participantsRef.current = p + 1; return p + 1; }));
api.addListener('participantLeft', () => setParticipants(p => { const n = Math.max(1, p - 1); participantsRef.current = n; return n; }));
api.addListener('readyToClose', onClose);
api.addListener('videoConferenceLeft', onClose);
// NOTE: we deliberately do NOT turn Jitsi's errorOccurred into a fatal
// overlay. On mobile / slow networks Jitsi fires transient connection
// errors and then reconnects on its own; showing "Call connection failed"
// on the first hiccup (and hiding the working prejoin behind it) was the
// "Could not start call" report. Let Jitsi's own UI handle its errors.
// Reveal = hand control to Jitsi's own UI (prejoin or conference) and stop
// treating "not joined yet" as a failure. Sitting on the prejoin page is
// normal, not an error.
const reveal = () => {
if (cancelled) return;
setReady(true);
clearTimeout(joinTimer);
};
api.addListener('readyToClose', reveal);
api.addListener('videoConferenceJoined', reveal);
// Jitsi's external_api has no single "iframe painted" event, so reveal
// shortly after creation โ the prejoin UI is visible within ~2.5s. Mobile
// paints slower, so give it a touch longer.
setTimeout(reveal, 2500);
// Last-resort timeout: only if the iframe NEVER painted (script blocked /
// server unreachable). Generous on mobile where meet.jit.si is slow to
// boot โ 30s so we never cut off a call that was about to connect.
joinTimer = setTimeout(() => {
// If Jitsi's iframe exists in the DOM, it painted โ don't claim failure,
// just reveal it and let the user use Jitsi's own retry.
const painted = containerRef.current && containerRef.current.querySelector('iframe');
if (cancelled) return;
if (painted) { setReady(true); }
else { setError('Could not reach the call server. Please End Call and try again.'); }
}, 30000);
}
if (window.JitsiMeetExternalAPI) {
join();
} else {
const sc = document.createElement('script');
sc.src = 'https://meet.jit.si/external_api.js';
sc.onload = join;
sc.onerror = () => setError('Could not load video call library');
document.head.appendChild(sc);
}
return () => {
cancelled = true;
clearTimeout(joinTimer);
if (frameRef.current) { try { frameRef.current.dispose(); } catch {} }
};
}, []);
// Duration timer
React.useEffect(() => {
const t = setInterval(() => setDuration(d => d + 1), 1000);
return () => clearInterval(t);
}, []);
function fmt(s) { const m = Math.floor(s/60); return `${String(m).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`; }
return (
{/* Top bar */}
{call.type === 'audio' ? '๐๏ธ' : '๐ฅ'} {call.channelName}
{fmt(duration)}
ยท {participants} participant{participants !== 1 ? 's' : ''}
{call.type === 'video' ? 'Screen share & recording available in toolbar below' : 'Voice call'}
End Call
{/* Jitsi Meet iframe mounts here */}
{!ready && !error && (
// pointerEvents:'none' so this loading overlay never swallows clicks
// meant for Jitsi's own prejoin ("Join meeting") button underneath โ
// that was what made the call look stuck on "Connecting to callโฆ".
)}
{error && (
Could not start call
{error}
)}
);
}
Object.assign(window, { TeamChat });
})();