// Mensagens internas — chat 1:1, grupos e comunicados da equipe (migration 052).
//
// POR QUE ESTE MODULO EXISTE
// A orientacao de trabalho da Promoup vivia no WhatsApp e se perdia. Aqui ela
// fica com historico, com contador de nao lidas e — no caso do comunicado —
// com registro de quem leu e de quem clicou em "Li e estou ciente".
//
// DECISOES QUE NAO SAO OBVIAS OLHANDO O CODIGO
// * A lista da esquerda le a VIEW v_minhas_conversas, nao a tabela. A view ja
// devolve o contador de nao lidas, o titulo e a foto do outro participante
// numa consulta so; ler a tabela obrigaria uma consulta por conversa.
// * Entrar em conversa, criar grupo e publicar comunicado passam por RPC.
// A tabela de participantes nao tem policy de insert de proposito: com ela,
// qualquer um se adicionaria a qualquer conversa.
// * O bucket de anexos e privado e a policy amarra o acesso a participacao na
// conversa. Por isso toda exibicao passa por createSignedUrl, nunca por URL
// publica.
// * "Lida" no chat sai de message_thread_members.last_read_at, comparado com
// a hora da mensagem. Nao existe tabela de "lido por" no chat normal: ela
// cresceria em usuarios x mensagens sem ninguem consultar depois.
const MSG_PRIORIDADES = {
normal: { label: 'Normal', cor: '#10B981', bg: '#F0FDF4', bola: '🟢' },
importante: { label: 'Importante', cor: '#F59E0B', bg: '#FFFBEB', bola: '🟡' },
urgente: { label: 'Urgente', cor: '#EF4444', bg: '#FEF2F2', bola: '🔴' },
};
const MSG_FILTROS = [
['todas', 'Todas'],
['nao_lidas', 'Não lidas'],
['conversas', 'Conversas'],
['comunicados', 'Comunicados'],
['arquivadas', 'Arquivadas'],
];
// Hora curta da lista: hoje mostra a hora, ontem mostra "Ontem", antes disso
// a data. Mesma regra do modulo de WhatsApp, para nao ter duas linguagens.
function fmtMsgHoraLista(iso) {
if (!iso) return '';
const d = new Date(iso);
const hoje = new Date();
if (d.toDateString() === hoje.toDateString()) {
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
const ontem = new Date(hoje);
ontem.setDate(hoje.getDate() - 1);
if (d.toDateString() === ontem.toDateString()) return 'Ontem';
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
}
function fmtMsgHora(iso) {
if (!iso) return '';
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function fmtMsgDataHora(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
});
}
// Separador de dia dentro da conversa.
function fmtMsgDiaSeparador(iso) {
const d = new Date(iso);
const hoje = new Date();
if (d.toDateString() === hoje.toDateString()) return 'Hoje';
const ontem = new Date(hoje);
ontem.setDate(hoje.getDate() - 1);
if (d.toDateString() === ontem.toDateString()) return 'Ontem';
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' });
}
function msgIdAleatorio() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : ((r & 0x3) | 0x8)).toString(16);
});
}
function msgExtensao(nome, mime) {
const porNome = (nome || '').split('.').pop();
if (porNome && porNome.length <= 5 && porNome !== nome) return porNome.toLowerCase();
const porMime = (mime || '').split('/')[1];
return (porMime || 'bin').toLowerCase();
}
function msgEhImagem(mime) {
return (mime || '').startsWith('image/');
}
// ============================================================
// Bolha de mensagem comum
// ============================================================
function MsgBolha({ msg, ehMinha, autor, citada, urls, lida, onResponder }) {
const prio = MSG_PRIORIDADES[msg.priority] || MSG_PRIORIDADES.normal;
const destaque = msg.priority !== 'normal';
const anexos = Array.isArray(msg.attachments) ? msg.attachments : [];
return (
{/* Em grupo, saber quem falou importa; em 1:1 seria ruido. */}
{!ehMinha && autor && (
{autor}
)}
{destaque && (
{prio.bola} {prio.label}
)}
{citada && (
{citada.texto}
)}
{anexos.map(a => {
const url = urls[a.path];
if (msgEhImagem(a.mime)) {
return url
?
:
Carregando imagem…
;
}
return (
{ if (!url) e.preventDefault(); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
background: '#00000008', borderRadius: 6, marginBottom: 6,
color: DS.colors.text, textDecoration: 'none', fontSize: 12, fontWeight: 600,
}}>
{a.name || 'Arquivo'}
);
})}
{msg.body &&
{msg.body}
}
onResponder && onResponder(msg)}
title="Responder"
style={{ cursor: 'pointer', fontWeight: 600 }}
>responder
{fmtMsgHora(msg.created_at)}
{ehMinha && (
{lida ? '✓✓' : '✓'}
)}
);
}
// ============================================================
// Card de comunicado — com o botao de ciencia
// ============================================================
function MsgComunicado({ msg, titulo, autor, urls, recibo, souGestor, souAutor, confirmando, onConfirmar, onVerStatus }) {
const prio = MSG_PRIORIDADES[msg.priority] || MSG_PRIORIDADES.normal;
const anexos = Array.isArray(msg.attachments) ? msg.attachments : [];
const jaConfirmou = !!(recibo && recibo.acknowledged_at);
return (
📢 Comunicado · {prio.label}
{autor} · {fmtMsgDataHora(msg.created_at)}
{titulo}
{msg.body}
{anexos.map(a => {
const url = urls[a.path];
if (msgEhImagem(a.mime)) {
return url
?
:
Carregando imagem…
;
}
return (
{ if (!url) e.preventDefault(); }}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 12px',
background: '#fff', border: `1px solid ${DS.colors.border}`, borderRadius: 6,
marginBottom: 10, color: DS.colors.text, textDecoration: 'none', fontSize: 12, fontWeight: 600,
}}>
{a.name || 'Arquivo'}
);
})}
{souAutor ? (
// Quem publicou nao precisa dar ciencia do proprio comunicado.
Você publicou este comunicado
) : jaConfirmou ? (
Você confirmou em {fmtMsgDataHora(recibo.acknowledged_at)}
) : (
{confirmando ? 'Confirmando…' : 'LI E ESTOU CIENTE'}
)}
{souGestor && (
Quem já leu
)}
);
}
// ============================================================
// Seletor de colaboradores reutilizado nos tres modais
// ============================================================
function MsgSeletorPessoas({ equipe, selecionados, onToggle, altura = 260 }) {
const [busca, setBusca] = React.useState('');
const filtrados = React.useMemo(() => {
const q = busca.trim().toLowerCase();
if (!q) return equipe;
return equipe.filter(u => (u.name || u.email || '').toLowerCase().includes(q)
|| (u.department || '').toLowerCase().includes(q));
}, [equipe, busca]);
return (
{filtrados.length === 0 && (
Ninguém encontrado.
)}
{filtrados.map(u => {
const marcado = selecionados.includes(u.id);
return (
onToggle(u.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
borderBottom: `1px solid ${DS.colors.border}`, cursor: 'pointer',
background: marcado ? DS.colors.primaryLight : '#fff',
}}>
{u.name || u.email}
{deptLabel(u.department)}
);
})}
);
}
// ============================================================
// Painel "quem leu / quem nao confirmou"
// ============================================================
function MsgStatusComunicado({ messageId, onClose, showToast }) {
const [linhas, setLinhas] = React.useState(null);
const [cobrando, setCobrando] = React.useState(false);
React.useEffect(() => {
let vivo = true;
sb.rpc('msg_status_comunicado', { p_message_id: messageId }).then(({ data, error }) => {
if (!vivo) return;
if (error) { showToast('Não foi possível carregar as confirmações: ' + error.message, 'danger'); setLinhas([]); return; }
setLinhas(data || []);
});
return () => { vivo = false; };
}, [messageId]);
const pendentes = (linhas || []).filter(l => !l.acknowledged_at);
const confirmaram = (linhas || []).filter(l => l.acknowledged_at);
const cobrar = async () => {
if (!pendentes.length) return;
setCobrando(true);
for (const p of pendentes) {
await createNotification({
type: 'comunicado',
title: 'Você ainda não confirmou um comunicado',
message: 'Abra Mensagens e clique em "Li e estou ciente".',
link: 'mensagens',
icon: 'alert',
target_user_id: p.user_id,
});
}
setCobrando(false);
showToast(`Lembrete enviado para ${pendentes.length} pessoa(s).`);
};
return (
{linhas === null ? : (
<>
{confirmaram.length} de {linhas.length} confirmaram
{pendentes.length > 0 && (
{cobrando ? 'Enviando…' : `Cobrar ${pendentes.length}`}
)}
{linhas.map(l => (
{l.name}
{deptLabel(l.department)}
{l.acknowledged_at ? (
<>
Confirmou
{fmtMsgDataHora(l.acknowledged_at)}
>
) : l.read_at ? (
<>
Leu, não confirmou
{fmtMsgDataHora(l.read_at)}
>
) : (
Não abriu
)}
))}
>
)}
);
}
// ============================================================
// Modal: nova conversa 1:1
// ============================================================
function ModalNovaConversa({ equipe, onClose, onEscolher }) {
const [busca, setBusca] = React.useState('');
const filtrados = React.useMemo(() => {
const q = busca.trim().toLowerCase();
if (!q) return equipe;
return equipe.filter(u => (u.name || u.email || '').toLowerCase().includes(q)
|| (u.department || '').toLowerCase().includes(q));
}, [equipe, busca]);
return (
{filtrados.length === 0 && (
Nenhum colaborador encontrado.
)}
{filtrados.map(u => (
onEscolher(u.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
borderBottom: `1px solid ${DS.colors.border}`, cursor: 'pointer',
}}
onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{u.name || u.email}
{deptLabel(u.department)}
))}
);
}
// ============================================================
// Modal: novo grupo
// ============================================================
function ModalNovoGrupo({ equipe, onClose, onCriado, showToast }) {
const [nome, setNome] = React.useState('');
const [sel, setSel] = React.useState([]);
const [salvando, setSalvando] = React.useState(false);
const toggle = (id) => setSel(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]));
const criar = async () => {
if (!nome.trim()) { showToast('Dê um nome ao grupo.', 'danger'); return; }
if (!sel.length) { showToast('Escolha pelo menos um participante.', 'danger'); return; }
setSalvando(true);
const { data, error } = await sb.rpc('msg_criar_grupo', { p_title: nome.trim(), p_member_ids: sel });
setSalvando(false);
if (error) { showToast('Erro ao criar grupo: ' + error.message, 'danger'); return; }
showToast('Grupo criado.');
onCriado(data);
};
return (
0}
extra={{salvando ? 'Criando…' : 'Criar grupo'} }>
);
}
// ============================================================
// Modal: novo comunicado
// ============================================================
function ModalComunicado({ equipe, onClose, onPublicado, showToast }) {
const [titulo, setTitulo] = React.useState('');
const [texto, setTexto] = React.useState('');
const [prio, setPrio] = React.useState('importante');
const [alvo, setAlvo] = React.useState('todos'); // todos | setor | pessoas
const [setor, setSetor] = React.useState('');
const [sel, setSel] = React.useState([]);
const [salvando, setSalvando] = React.useState(false);
const toggle = (id) => setSel(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]));
const quantos = alvo === 'todos'
? equipe.length
: alvo === 'setor'
? equipe.filter(u => u.department === setor).length
: sel.length;
const publicar = async () => {
if (!titulo.trim() || !texto.trim()) { showToast('Preencha título e texto.', 'danger'); return; }
if (alvo === 'setor' && !setor) { showToast('Escolha o setor.', 'danger'); return; }
if (alvo === 'pessoas' && !sel.length) { showToast('Escolha ao menos um destinatário.', 'danger'); return; }
if (!quantos) { showToast('Esse destino não tem ninguém.', 'danger'); return; }
setSalvando(true);
const { data, error } = await sb.rpc('msg_publicar_comunicado', {
p_title: titulo.trim(),
p_body: texto.trim(),
p_priority: prio,
p_member_ids: alvo === 'pessoas' ? sel : [],
p_department: alvo === 'setor' ? setor : null,
p_todos: alvo === 'todos',
p_attachments: [],
});
setSalvando(false);
if (error) { showToast('Erro ao publicar: ' + error.message, 'danger'); return; }
showToast('Comunicado publicado.');
onPublicado(data);
};
return (
{salvando ? 'Publicando…' : 'Publicar'}}>
Texto
Prioridade
{Object.keys(MSG_PRIORIDADES).map(k => {
const p = MSG_PRIORIDADES[k];
const ativo = prio === k;
return (
setPrio(k)}
style={{
flex: 1, padding: '8px 10px', borderRadius: DS.radius.sm, cursor: 'pointer',
fontSize: 12, fontWeight: 700,
background: ativo ? p.bg : '#fff',
color: ativo ? p.cor : DS.colors.textSec,
border: `1px solid ${ativo ? p.cor : DS.colors.border}`,
}}>{p.bola} {p.label}
);
})}
Quem vai receber — {quantos} pessoa{quantos !== 1 ? 's' : ''}
{[['todos', 'Toda a equipe'], ['setor', 'Um setor'], ['pessoas', 'Pessoas específicas']].map(([v, l]) => (
setAlvo(v)}
style={{
flex: 1, padding: '8px 10px', borderRadius: DS.radius.sm, cursor: 'pointer',
fontSize: 12, fontWeight: 600,
background: alvo === v ? DS.colors.primary : '#fff',
color: alvo === v ? '#fff' : DS.colors.textSec,
border: alvo === v ? 'none' : `1px solid ${DS.colors.border}`,
}}>{l}
))}
{alvo === 'setor' && (
)}
{alvo === 'pessoas' && (
)}
);
}
// ============================================================
// Modal: participantes do grupo
// ============================================================
function ModalParticipantes({ thread, membros, equipe, onClose, onMudou, showToast }) {
const idsAtuais = membros.map(m => m.user_id);
const [sel, setSel] = React.useState(idsAtuais);
const [salvando, setSalvando] = React.useState(false);
const toggle = (id) => setSel(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]));
const salvar = async () => {
const add = sel.filter(id => !idsAtuais.includes(id));
const remove = idsAtuais.filter(id => !sel.includes(id));
if (!add.length && !remove.length) { onClose(); return; }
setSalvando(true);
const { error } = await sb.rpc('msg_gerenciar_membros', {
p_thread: thread.id, p_add: add, p_remove: remove,
});
setSalvando(false);
if (error) { showToast('Erro ao salvar participantes: ' + error.message, 'danger'); return; }
showToast('Participantes atualizados.');
onMudou();
onClose();
};
return (
{salvando ? 'Salvando…' : 'Salvar'}}>
Quem está marcado participa de {thread.titulo || thread.title} . Quem criou a conversa não pode ser removido.
);
}
// ============================================================
// MODULO
// ============================================================
function Mensagens({ currentUser, onNavigate }) {
const meuId = currentUser?.id || null;
const souGestao = currentUser?.role === 'admin' || currentUser?.department === 'admin';
const [conversas, setConversas] = React.useState([]);
const [ativaId, setAtivaId] = React.useState(null);
const [mensagens, setMensagens] = React.useState([]);
const [membros, setMembros] = React.useState([]);
const [recibos, setRecibos] = React.useState({}); // message_id -> recibo
const [equipe, setEquipe] = React.useState([]);
const [urls, setUrls] = React.useState({}); // path -> signed url
const [loading, setLoading] = React.useState(true);
const [carregandoMsgs, setCarregandoMsgs] = React.useState(false);
const [filtro, setFiltro] = React.useState('todas');
const [busca, setBusca] = React.useState('');
const [draft, setDraft] = React.useState('');
const [prioridade, setPrioridade] = React.useState('normal');
const [respondendo, setRespondendo] = React.useState(null);
const [enviando, setEnviando] = React.useState(false);
const [anexando, setAnexando] = React.useState(false);
const [confirmando, setConfirmando] = React.useState(false);
const [toast, setToast] = React.useState(null);
const [modalNova, setModalNova] = React.useState(false);
const [modalGrupo, setModalGrupo] = React.useState(false);
const [modalComunicado, setModalComunicado] = React.useState(false);
const [modalParticipantes, setModalParticipantes] = React.useState(false);
const [statusDe, setStatusDe] = React.useState(null);
const fimRef = React.useRef(null);
const fileRef = React.useRef(null);
const ativaIdRef = React.useRef(null);
React.useEffect(() => { ativaIdRef.current = ativaId; }, [ativaId]);
const showToast = (msg, type = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3200);
};
const ativa = React.useMemo(
() => conversas.find(c => c.id === ativaId) || null,
[conversas, ativaId],
);
const equipePorId = React.useMemo(() => {
const m = {};
equipe.forEach(u => { m[u.id] = u; });
return m;
}, [equipe]);
const nomeDe = (id) => {
const u = equipePorId[id];
return u ? (u.name || u.email) : 'Colaborador';
};
// ---------- Carga da lista de conversas ----------
const carregarConversas = React.useCallback(async () => {
const { data, error } = await sb.from('v_minhas_conversas').select('*');
if (error) {
console.error('[mensagens] falha ao carregar conversas:', error);
setLoading(false);
return;
}
const lista = (data || []).map(c => ({ ...c, nao_lidas: Number(c.nao_lidas) || 0 }));
lista.sort((a, b) => {
if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1;
return new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at);
});
setConversas(lista);
setLoading(false);
}, []);
React.useEffect(() => { carregarConversas(); }, [carregarConversas]);
// Equipe interna, para os seletores e para o nome de quem escreveu.
React.useEffect(() => {
sb.from('app_users').select('id, name, email, department, role, photo_url').order('name')
.then(({ data }) => setEquipe(data || []));
}, []);
// Vem da Agenda: "Comunicação da equipe" grava o id e navega para ca.
React.useEffect(() => {
const pre = localStorage.getItem('mensagens_prefill_thread_id');
if (pre) {
localStorage.removeItem('mensagens_prefill_thread_id');
setAtivaId(pre);
}
}, []);
// ---------- Carga da conversa aberta ----------
const carregarMensagens = React.useCallback(async (threadId) => {
if (!threadId) { setMensagens([]); setMembros([]); setRecibos({}); return; }
setCarregandoMsgs(true);
const [msgRes, memRes] = await Promise.all([
sb.from('messages').select('*').eq('thread_id', threadId)
.is('deleted_at', null).order('created_at', { ascending: true }).limit(300),
sb.from('message_thread_members').select('user_id, role, last_read_at, muted').eq('thread_id', threadId),
]);
if (msgRes.error) {
console.error('[mensagens] falha ao carregar mensagens:', msgRes.error);
setCarregandoMsgs(false);
return;
}
const msgs = msgRes.data || [];
setMensagens(msgs);
setMembros(memRes.data || []);
// Recibos so existem em comunicado; a RLS devolve apenas os meus.
const idsAck = msgs.filter(m => m.requires_ack).map(m => m.id);
if (idsAck.length) {
const { data: rec } = await sb.from('message_receipts')
.select('message_id, read_at, acknowledged_at').in('message_id', idsAck);
const mapa = {};
(rec || []).forEach(r => { mapa[r.message_id] = r; });
setRecibos(mapa);
} else {
setRecibos({});
}
setCarregandoMsgs(false);
}, []);
React.useEffect(() => { carregarMensagens(ativaId); }, [ativaId, carregarMensagens]);
// Marca como lida ao abrir e zera o contador local sem esperar recarga.
React.useEffect(() => {
if (!ativaId) return;
sb.rpc('msg_marcar_lida', { p_thread: ativaId }).then(({ error }) => {
if (error) { console.warn('[mensagens] marcar lida falhou:', error.message); return; }
setConversas(prev => prev.map(c => c.id === ativaId ? { ...c, nao_lidas: 0, last_read_at: new Date().toISOString() } : c));
});
}, [ativaId, mensagens.length]);
// ---------- URLs assinadas dos anexos ----------
// O bucket e privado de proposito: sem isso, quem soubesse o caminho leria
// anexo de conversa alheia. Entao toda exibicao precisa de URL assinada.
React.useEffect(() => {
const paths = [];
mensagens.forEach(m => {
(Array.isArray(m.attachments) ? m.attachments : []).forEach(a => {
if (a && a.path && !urls[a.path]) paths.push(a.path);
});
});
if (!paths.length) return;
let vivo = true;
sb.storage.from('mensagens-anexos').createSignedUrls(paths, 3600).then(({ data, error }) => {
if (!vivo || error || !data) {
if (error) console.warn('[mensagens] signed urls:', error.message);
return;
}
setUrls(prev => {
const novo = { ...prev };
data.forEach(d => { if (d.signedUrl) novo[d.path] = d.signedUrl; });
return novo;
});
});
return () => { vivo = false; };
}, [mensagens]);
// ---------- Realtime ----------
React.useEffect(() => {
const canal = sb.channel('mensagens-internas')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, payload => {
const m = payload.new;
if (m.thread_id === ativaIdRef.current) {
setMensagens(prev => (prev.some(x => x.id === m.id) ? prev : [...prev, m]));
}
carregarConversas();
})
// Sem isto o "✓✓" nunca acende sozinho: quem le atualiza last_read_at na
// propria linha de participante, e o remetente precisa saber disso ao vivo.
.on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'message_thread_members' }, payload => {
const linha = payload.new;
if (linha && linha.thread_id === ativaIdRef.current) {
setMembros(prev => prev.map(m => (m.user_id === linha.user_id ? { ...m, ...linha } : m)));
}
carregarConversas();
})
.on('postgres_changes', { event: '*', schema: 'public', table: 'message_threads' }, () => {
carregarConversas();
})
.subscribe();
return () => { sb.removeChannel(canal); };
}, [carregarConversas]);
// Rola para o fim quando chega mensagem.
React.useEffect(() => {
if (fimRef.current) fimRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [mensagens]);
// ---------- Push no celular ----------
// Dispara depois que o insert deu certo e nao bloqueia a tela: se a Edge
// Function falhar, a mensagem ja esta salva — quem falhou foi o aviso.
const avisarPorPush = (messageId) => {
if (!messageId) return;
sb.functions.invoke('push-send', { body: { message_id: messageId } })
.then(({ error }) => { if (error) console.warn('[mensagens] push nao saiu:', error.message); })
.catch(err => console.warn('[mensagens] push nao saiu:', err && err.message));
};
const [pushLigado, setPushLigado] = React.useState(false);
const [mexendoPush, setMexendoPush] = React.useState(false);
React.useEffect(() => { pushLigadoNesteAparelho().then(setPushLigado); }, []);
const alternarPush = async () => {
setMexendoPush(true);
if (pushLigado) {
await desativarPush();
setPushLigado(false);
showToast('Notificações desligadas neste aparelho.');
} else {
const r = await ativarPush(meuId);
setPushLigado(r.ok);
showToast(r.motivo, r.ok ? 'success' : 'danger');
}
setMexendoPush(false);
};
// ---------- Enviar ----------
const enviar = async () => {
const texto = draft.trim();
if (!texto || !ativaId || enviando) return;
setEnviando(true);
setDraft('');
const { data, error } = await sb.from('messages').insert({
thread_id: ativaId,
sender_id: meuId,
body: texto,
priority: prioridade,
reply_to_id: respondendo ? respondendo.id : null,
}).select().single();
setEnviando(false);
if (error) {
setDraft(texto);
showToast('Não foi possível enviar: ' + error.message, 'danger');
return;
}
setRespondendo(null);
setPrioridade('normal');
// O realtime tambem traz, mas so depois do round-trip: colocar aqui deixa
// a propria mensagem aparecer na hora.
setMensagens(prev => (prev.some(x => x.id === data.id) ? prev : [...prev, data]));
avisarPorPush(data.id);
carregarConversas();
};
// ---------- Anexar ----------
const anexar = async (e) => {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file || !ativaId) return;
if (file.size > 20 * 1024 * 1024) {
showToast('Arquivo grande demais (máximo 20MB).', 'danger');
return;
}
setAnexando(true);
try {
let corpo = file;
let mime = file.type || 'application/octet-stream';
let ext = msgExtensao(file.name, mime);
// prepararFoto reencoda a imagem: no Android o File do input some ao voltar
// da camera e o upload chega vazio. Ja resolvido no shared.jsx.
if (msgEhImagem(mime)) {
const preparada = await prepararFoto(file);
corpo = preparada.blob;
mime = preparada.tipo;
ext = preparada.ext;
}
const path = `${ativaId}/${msgIdAleatorio()}.${ext}`;
const { error: upErr } = await sb.storage.from('mensagens-anexos')
.upload(path, corpo, { contentType: mime, upsert: false });
if (upErr) throw upErr;
const anexo = { path, name: file.name || `arquivo.${ext}`, mime, size: file.size };
const { data, error } = await sb.from('messages').insert({
thread_id: ativaId,
sender_id: meuId,
body: draft.trim() || null,
priority: prioridade,
attachments: [anexo],
reply_to_id: respondendo ? respondendo.id : null,
}).select().single();
if (error) {
// Sem rollback o arquivo fica orfao no bucket, ocupando espaco a toa.
await sb.storage.from('mensagens-anexos').remove([path]);
throw error;
}
setDraft('');
setRespondendo(null);
setPrioridade('normal');
setMensagens(prev => (prev.some(x => x.id === data.id) ? prev : [...prev, data]));
avisarPorPush(data.id);
carregarConversas();
} catch (err) {
showToast(mensagemErroUpload(err), 'danger');
} finally {
setAnexando(false);
}
};
// ---------- Acoes de conversa ----------
const abrirConversaCom = async (userId) => {
const { data, error } = await sb.rpc('msg_abrir_conversa', { p_user_id: userId });
if (error) { showToast('Erro ao abrir conversa: ' + error.message, 'danger'); return; }
setModalNova(false);
await carregarConversas();
setAtivaId(data);
};
const confirmarCiencia = async (messageId) => {
setConfirmando(true);
const { data, error } = await sb.rpc('msg_confirmar_ciencia', { p_message_id: messageId });
setConfirmando(false);
if (error) { showToast('Erro ao confirmar: ' + error.message, 'danger'); return; }
setRecibos(prev => ({
...prev,
[messageId]: { ...(prev[messageId] || {}), acknowledged_at: data, read_at: (prev[messageId] || {}).read_at || data },
}));
showToast('Ciência registrada. Obrigado!');
};
const alternarFixado = async () => {
if (!ativa) return;
const { error } = await sb.from('message_threads').update({ is_pinned: !ativa.is_pinned }).eq('id', ativa.id);
if (error) { showToast('Não foi possível fixar: ' + error.message, 'danger'); return; }
carregarConversas();
};
const alternarArquivada = async () => {
if (!ativa) return;
const { error } = await sb.from('message_threads').update({ is_archived: !ativa.is_archived }).eq('id', ativa.id);
if (error) { showToast('Não foi possível arquivar: ' + error.message, 'danger'); return; }
showToast(ativa.is_archived ? 'Conversa reativada.' : 'Conversa arquivada.');
carregarConversas();
};
// ---------- Lista filtrada ----------
const listaVisivel = React.useMemo(() => {
const q = busca.trim().toLowerCase();
return conversas.filter(c => {
if (filtro === 'arquivadas') { if (!c.is_archived) return false; }
else if (c.is_archived) return false;
if (filtro === 'nao_lidas' && !(c.nao_lidas > 0)) return false;
if (filtro === 'conversas' && c.kind === 'announcement') return false;
if (filtro === 'comunicados' && c.kind !== 'announcement') return false;
if (!q) return true;
return (c.titulo || '').toLowerCase().includes(q)
|| (c.last_message_preview || '').toLowerCase().includes(q);
});
}, [conversas, filtro, busca]);
const totalNaoLidas = React.useMemo(
() => conversas.reduce((s, c) => s + (c.is_archived ? 0 : c.nao_lidas), 0),
[conversas],
);
// "Lida" = todos os outros participantes leram depois da hora da mensagem.
const foiLidaPorTodos = (msg) => {
const outros = membros.filter(m => m.user_id !== meuId);
if (!outros.length) return false;
return outros.every(m => m.last_read_at && new Date(m.last_read_at) >= new Date(msg.created_at));
};
const iconeDaConversa = (c) => {
if (c.kind === 'announcement') return 'bell';
if (c.kind === 'group') return 'users';
if (c.kind === 'event') return 'calendar';
return 'message';
};
// ============================================================
// RENDER
// ============================================================
if (loading) {
return (
Mensagens
);
}
const souAdminDaConversa = ativa && (ativa.meu_papel === 'admin' || currentUser?.role === 'admin');
const podeGerenciar = ativa && ativa.kind !== 'direct' && souAdminDaConversa;
return (
{/* Cabecalho */}
Mensagens
Canal interno da equipe · {totalNaoLidas} não lida{totalNaoLidas !== 1 ? 's' : ''}
{souGestao && (
setModalComunicado(true)}>Novo comunicado
)}
{pushLigado ? "Push ligado" : "Receber no celular"}
setModalGrupo(true)}>Novo grupo
setModalNova(true)}>Nova conversa
{/* ---------------- Coluna: conversas ---------------- */}
{MSG_FILTROS.map(([val, label]) => (
setFiltro(val)}
style={{
padding: '4px 9px', borderRadius: 999, fontSize: 11, fontWeight: 600, cursor: 'pointer',
background: filtro === val ? DS.colors.primary : '#fff',
color: filtro === val ? '#fff' : DS.colors.textSec,
border: filtro === val ? 'none' : `1px solid ${DS.colors.border}`,
}}>{label}
))}
{listaVisivel.length === 0 && (
{busca || filtro !== 'todas'
? 'Nada por aqui com esse filtro.'
: 'Nenhuma conversa ainda. Comece por "Nova conversa".'}
)}
{listaVisivel.map(c => {
const selecionada = c.id === ativaId;
const prio = MSG_PRIORIDADES[c.priority] || MSG_PRIORIDADES.normal;
return (
setAtivaId(c.id)}
style={{
display: 'flex', gap: 10, padding: '11px 12px', cursor: 'pointer',
borderBottom: `1px solid ${DS.colors.border}`,
background: selecionada ? '#fff' : 'transparent',
borderLeft: selecionada ? `3px solid ${DS.colors.primary}` : '3px solid transparent',
}}>
{c.kind === 'direct'
?
: (
)}
{c.is_pinned &&
}
0 ? 800 : 600, color: DS.colors.text,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
}}>
{c.titulo || 'Sem título'}
{fmtMsgHoraLista(c.last_message_at)}
0 ? DS.colors.text : DS.colors.textSec,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
}}>
{c.kind === 'announcement' ? '📢 ' : ''}
{c.last_message_preview || 'Sem mensagens ainda'}
{c.nao_lidas > 0 && (
{c.nao_lidas > 99 ? '99+' : c.nao_lidas}
)}
);
})}
{/* ---------------- Painel: conversa aberta ---------------- */}
{!ativa ? (
Selecione uma conversa
As mensagens chegam em tempo real. Comunicados aparecem com o botão de confirmação de leitura.
) : (
{/* Cabecalho da conversa */}
{ativa.kind === 'direct'
?
: (
)}
{ativa.titulo || 'Sem título'}
{ativa.kind === 'direct'
? 'Conversa individual'
: `${membros.length} participante${membros.length !== 1 ? 's' : ''}`}
{ativa.kind === 'event' && ativa.event_id ? ' · Comunicação da ação' : ''}
{ativa.is_archived ? ' · Arquivada' : ''}
{ativa.kind === 'event' && ativa.event_id && (
{
localStorage.setItem('events_prefill_open_id', String(ativa.event_id));
if (onNavigate) onNavigate('events');
}}>Ver ação
)}
{podeGerenciar && (
setModalParticipantes(true)}>Participantes
)}
{souAdminDaConversa && (
<>
{ativa.is_pinned ? 'Desafixar' : 'Fixar'}
{ativa.is_archived ? 'Reativar' : 'Arquivar'}
>
)}
{/* Historico */}
{carregandoMsgs &&
}
{!carregandoMsgs && mensagens.length === 0 && (
Nenhuma mensagem ainda. Escreva a primeira abaixo.
)}
{mensagens.map((m, i) => {
const anterior = i > 0 ? mensagens[i - 1] : null;
const trocouDia = !anterior
|| new Date(anterior.created_at).toDateString() !== new Date(m.created_at).toDateString();
let citada = null;
if (m.reply_to_id) {
const alvo = mensagens.find(x => x.id === m.reply_to_id);
if (alvo) citada = { texto: `${nomeDe(alvo.sender_id)}: ${(alvo.body || 'anexo').slice(0, 120)}` };
}
return (
{trocouDia && (
{fmtMsgDiaSeparador(m.created_at)}
)}
{m.requires_ack ? (
confirmarCiencia(m.id)}
onVerStatus={() => setStatusDe(m.id)}
/>
) : (
)}
);
})}
{/* Caixa de escrita */}
{respondendo && (
Respondendo {nomeDe(respondendo.sender_id)}: {(respondendo.body || 'anexo').slice(0, 80)}
setRespondendo(null)} style={{ padding: 4 }} />
)}
setPrioridade(e.target.value)}
title="Prioridade da mensagem"
style={{
border: `1px solid ${DS.colors.border}`, borderRadius: DS.radius.sm,
padding: '9px 6px', fontSize: 13, background: '#fff', cursor: 'pointer',
fontFamily: 'inherit', flexShrink: 0,
}}>
{Object.keys(MSG_PRIORIDADES).map(k => (
{MSG_PRIORIDADES[k].bola}
))}
fileRef.current && fileRef.current.click()}
disabled={anexando}
title="Anexar arquivo"
style={{
width: 38, height: 38, borderRadius: '50%', border: `1px solid ${DS.colors.border}`,
background: '#fff', cursor: anexando ? 'wait' : 'pointer', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
)}
{/* ---------------- Modais ---------------- */}
{modalNova && (
u.id !== meuId)}
onClose={() => setModalNova(false)}
onEscolher={abrirConversaCom}
/>
)}
{modalGrupo && (
u.id !== meuId)}
onClose={() => setModalGrupo(false)}
showToast={showToast}
onCriado={async (id) => { setModalGrupo(false); await carregarConversas(); setAtivaId(id); }}
/>
)}
{modalComunicado && (
u.id !== meuId)}
onClose={() => setModalComunicado(false)}
showToast={showToast}
onPublicado={async (id) => { setModalComunicado(false); await carregarConversas(); setAtivaId(id); }}
/>
)}
{modalParticipantes && ativa && (
setModalParticipantes(false)}
showToast={showToast}
onMudou={() => carregarMensagens(ativa.id)}
/>
)}
{statusDe && (
setStatusDe(null)}
showToast={showToast}
/>
)}
{toast && setToast(null)} />}
);
}
Object.assign(window, {
Mensagens, MSG_PRIORIDADES, MSG_FILTROS,
fmtMsgHoraLista, fmtMsgHora, fmtMsgDataHora,
});