Mapa do código
Resumo em uma frase: três camadas fixas (main, preload, renderer), e dentro do renderer uma separação estrita entre lógica (services/), ponte com o React (hooks/) e interface (components/).
Esta página existe pra alguém abrir o repositório pela primeira vez e achar rápido "onde mexo pra fazer X" — não é uma lista exaustiva de todo arquivo, é um mapa de responsabilidades.
As três camadas do Electron
src/
main/ processo principal (Node.js puro, sem DOM) — janela, IPC, updater, settings.json, logs
preload/ ponte contextBridge — o ÚNICO jeito do renderer falar com o main
renderer/ processo de UI (Chromium) — React + TypeScript + Tailwind
shared/ tipos e constantes usados dos dois lados (main E renderer), sem lógica de nenhum dos doisPor que essa separação existe: o renderer roda com contextIsolation: true e nodeIntegration: false — ele não tem acesso a Node.js, sistema de arquivos ou APIs do Electron diretamente. Tudo que o renderer precisa do sistema operacional (abrir/fechar janela, ler/escrever settings.json, capturar teclado global, etc.) passa por uma função exposta no preload/index.ts via contextBridge.exposeInMainWorld('api', ...), que internamente chama ipcRenderer.invoke/.send pro main processar. Ver Segurança do processo principal mais abaixo.
src/main/ — processo principal
| Arquivo | Responsabilidade |
|---|---|
index.ts | Ponto de entrada. Configura flags do Chromium, lê settings.json, cria a janela, registra os handlers de IPC e inicia o updater. |
window.ts | Cria a BrowserWindow (sem moldura nativa — a barra de título é toda customizada em React) e intercepta o evento close pra dar tempo do som de fechar tocar antes da janela morrer de verdade. |
ipc.ts | Todos os handlers ipcMain.handle/ipcMain.on num só lugar — é o "menu" completo de tudo que o renderer pode pedir pro main fazer. |
settings.ts | Lê/escreve settings.json (fica em app.getPath('userData')) — auto-update, atalhos de teclado, flags experimentais. Isso é diferente do localStorage do renderer (ver Onde cada preferência mora abaixo). |
toggleHotkeys.ts | Registra mutar mic/áudio como atalhos globais via globalShortcut do Electron — funcionam com outro programa em foco, sem depender de nenhuma lib nativa. |
audioLoopback.ts | Áudio isolado por aplicativo (WASAPI Process Loopback) via loopback-capture + node-window-manager — dependência nativa, opt-in. |
updater.ts | electron-updater: verifica, baixa e instala atualizações via GitHub Releases. |
logger.ts | Lê/escreve o histórico de logs (logs.jsonl), consumido pela tela de Logs do app. |
constants/ | Uma constante nomeada por arquivo/tema (nunca valor solto no meio do código) — janela, captura, logging, atualização, etc. |
strings/ | Strings visíveis ao usuário que se originam no processo principal (ex: aviso de captura experimental, mensagens de log de teclas de atalho). |
src/preload/index.ts — a ponte
Um único arquivo. Cada função exposta em window.api corresponde a um canal de IPC declarado em src/shared/ipcChannels.ts. Se você precisa que o renderer converse com o sistema operacional de um jeito novo, o fluxo é sempre:
- Adicionar o canal em
shared/ipcChannels.ts. - Registrar o handler em
main/ipc.ts(ou noutro arquivo domain/, chamado a partir dali). - Expor a função em
preload/index.ts. - Declarar o tipo dela em
renderer/src/global.d.ts(a interfaceWindow.apié escrita à mão, não gerada — ver nota abaixo).
Nota:
global.d.tsnão deriva automaticamente dopreload/index.ts— são dois lugares que precisam ficar em sincronia manualmente. Esquecer o passo 4 dá erro de TypeScript só na hora de compilar orenderer, não opreload.
src/renderer/src/services/ — lógica de negócio, sem DOM
Regra de ferro: nada aqui importa de components/ ou hooks/. Serviços não sabem que o React existe. Eles expõem estado e eventos através de EventTarget/CustomEvent — um padrão framework-agnostic de propósito, pra sobreviver a qualquer troca futura de UI.
RoomClient.ts— o orquestrador central de uma sala. Uma sala inteira (mesh P2P, chat, voz, compartilhamento) é uma instância desta classe. É totalmente autocontida (sem estado global/singleton), por isso o app consegue ter várias salas abertas ao mesmo tempo. Delega quase tudo pros sub-serviços deservices/room/.services/room/— os sub-serviços que oRoomClientorquestra:PeerConnectionManager— ciclo de vida das conexões WebRTC (abrir, aceitar, fechar, reconectar), distingue chamadas de vídeo e de voz.RoomAuthController— aperto de mão inicial (hello), checagem de versão do app, aprovação manual de quem entra.RoomProtocol— interpreta as mensagens do protocolo (apresentação, lista de membros, status de compartilhamento/microfone, pedidos de assistir, remoção, chat, exclusão/edição/reação de mensagem).MemberRegistry— estado de quem está na sala, do ponto de vista de cada participante.MembershipGossip— propaga a lista de membros pra malha inteira (protocolo de "fofoca").MediaSharing— envia vídeo/áudio da tela só pra quem pediu pra assistir.VoiceChat— chamada de voz broadcast entre todos os membros, com retry/timeout de conexão.ChatService— histórico de chat em memória (nunca persistido), envio/recebimento/exclusão/edição/reação de mensagem. Responder/reagir/editar (v0.36.50+):ChatMessageEntryganhoureplyToId(opcional, aponta proidde outra mensagem — a UI busca o texto original numMaplocal, sem exigir round-trip de rede),edited(marca "(editado)") ereactions({ emoji, fromIds[] }[],fromIdsguardaSELF_SENDER_IDou ofromIdde quem reagiu). Mensagens novas no protocolo:edit-message(só o autor original pode editar, checado emreceiveEditcomparandomessage.fromId === fromId) ereact-message(toggle — reagir de novo com o mesmo emoji remove a própria reação, viaapplyReactionToggle).peerSession.ts,iceDiagnostics.ts,turnCredentials.ts,videoBitrate.ts— utilidades de baixo nível (geração de código de sala, diagnóstico de ICE, config STUN-only, ajuste de bitrate de vídeo numa conexão já aberta).
MicCapture.ts— captura o microfone via um grafo do Web Audio API (MediaStreamSource → GainNode → MediaStreamAudioDestinationNode), pra permitir ajustar sensibilidade e mutar na origem (zerando o gain), não só desativando atrack.ScreenCapture.ts— captura de tela/janela viadesktopCapturerdo Electron.soundEffects.ts— todos os efeitos sonoros do app, umplayXSound()por ação, cada um com sua categoria de volume (ver Sistema de sons).*Preference.ts(soundEffectsPreference,uiScalePreference,micInputPreference,audioOutputPreference,avatarPreference,namePreference) — preferências do usuário guardadas nolocalStoragedo renderer, cada uma com seu próprioget/set/subscribe. O nome (namePreference.ts, v0.36.23+) segue o mesmo padrão do avatar: salvo automaticamente sempre que muda, sem precisar de um botão "salvar" — assim quem já preencheu nome/avatar uma vez não precisa digitar de novo em Criar sala, Entrar numa sala ou Contatos, já vem tudo preenchido.appLog.ts— grava eventos no histórico de logs a partir do renderer (chama omainvia IPC).
src/renderer/src/hooks/ — a ponte entre services/ e o React
Serviços expõem estado via EventTarget; hooks traduzem isso pra algo que o React entende, quase sempre com useSyncExternalStore — é o padrão usado em praticamente todo hook de preferência (useSoundEffectsVolume, useUiScale, useMicInputDevice, etc.) e de estado de sala (useMembers, useSharing, useChatMessages, useMicMuted).
useRoomSessions.ts é o hook mais importante do app: gerencia a lista de sessões de sala abertas ao mesmo tempo (cada uma com seu próprio RoomClient) e qual está em foco — ver Múltiplas salas ao mesmo tempo abaixo.
src/renderer/src/components/ — interface
Um componente por pasta quando tem subcomponentes próprios (ex: Room/, SettingsScreen/, ShareControls/), cada um com seu .types.ts e, quando tem variantes visuais, um .variants.ts usando tailwind-variants. App.tsx é a composition root — o único lugar que decide o layout geral e qual overlay (Configurações, Logs, Atualizações, Adicionar sala) está ativo.
Árvore simplificada de quem renderiza quem, dentro de uma sala:
App
├─ TitleBar (logo, versão, atalhos de janela)
├─ RoomSwitcher (lista de sessões abertas, se houver mais de uma)
└─ Room (só a sessão em FOCO monta isto — as outras ficam só como dados)
├─ RoomToolbar (mic, áudio, logs, configurações)
├─ RoomSidebar (código/senha da sala, participantes)
├─ RoomStage (grade de vídeos sendo assistidos)
├─ ShareControls (iniciar/editar/parar compartilhamento)
├─ Chat
├─ VoiceAudioSinks (elementos <audio> de cada voz — sempre montados, independente da sidebar)
└─ JoinRequestModal (se você for dono da sala)Chat com Markdown e highlight de código (v0.36.49+)
ChatMessageText.tsxpassou a renderizar Markdown de verdade viareact-markdown(+remark-gfmpra tabelas/strikethrough/autolinks,rehype-highlight+highlight.jspro highlight de bloco de código). Escolhidoreact-markdown(renderiza pra elementos React diretamente) em vez de converter markdown→HTML string comdangerouslySetInnerHTML— mensagens vêm de qualquer peer da sala, então evitar HTML bruto é a diferença entre "formatação" e "XSS via chat".- Links continuam abrindo via
window.api.openExternalUrl(nunca navegação direta) — antes era um parser de URL feito à mão (ChatMessageText.tsxantigo), agora é umcomponents.acustomizado doreact-markdownfazendo a mesma coisa. O parser manual foi removido. ChatInput.tsxtrocou de<input>de uma linha pra<textarea>(Enter envia, Shift+Enter quebra linha) — necessário pra digitar bloco de código cercado (```) ou lista de verdade, que precisam de múltiplas linhas.CHAT_MESSAGE_MAX_LENGTHsubiu de 500 pra 2000 (constants/chat.ts) pra caber blocos de código maiores.- Tema de highlight (
highlight.js/styles/atom-one-dark.css) importado globalmente emmain.tsx, junto doindex.css— é uma folha de estilo de terceiro completa (não tokens Tailwind), aceito como exceção pelo mesmo motivo do@font-facedo Jersey10: biblioteca de sintaxe colorida não cabe no sistema de tokens do tema retrô sem reescrever na mão. Ainda não recolorido pra combinar com a paleta cinza/navy — item de polimento futuro, não bloqueante. - Imagens em Markdown (
) continuam bloqueadas pela CSP (img-src 'self' data:) se apontarem pra fora do app — comportamento existente, não uma feature nova de preview de imagem (isso é outra frente, envio de arquivo binário via WebRTC, ainda não implementada).
Tela de Perfil, status por sala, perfil obrigatório antes de entrar em sala (v0.36.48+)
- Tela de Perfil nova (
components/ProfileScreen/), acessível pelo ícone na barra de título (ao lado de Contatos) e por um botão na tela inicial (PreRoomChoice). Nome e avatar saíram deContactsScreen.tsx(que agora só lista contatos) pra cá, junto de um campo novo: status (texto livre, opcional,constants/status.tslimita a 60 caracteres). - Status broadcast pelo protocolo de sala, mesmo caminho de nome/avatar:
services/statusPreference.ts(padrãonamePreference.ts,EventTarget+useSyncExternalStore) →RoomAuthController.sendHelloincluistatusnohello→RoomProtocol.handleHellograva noMemberRegistry→MembershipGossippropaga pra malha inteira igual nome/avatar. Só fixo por sessão (igual nome/avatar hoje) — mudar o status no meio de uma sala já aberta não atualiza os outros participantes, mesma limitação que já existia pra nome/avatar. ParticipantTile.tsxmostra o status de cada membro (linha pequena e discreta abaixo do nome) quando ele tiver um definido.- Perfil (nome preenchido) virou obrigatório antes de qualquer entrada em sala —
services/namePreference.tsganhouhasProfileConfigured()(nome não-vazio) e um hook reativo (useHasProfileConfigured). Novo componente compartilhadoProfileRequiredNotice.tsx(mensagem + botão "Ir pro Perfil") aparece em vez de deixar prosseguir em: criar sala, entrar numa sala, chamar um contato (ContactsScreen,RecentContactsPanelda tela inicial) e abrir a sala pessoal (PersonalIdPanel). - Bug de navegação real encontrado e corrigido nesse meio tempo: abrir o Perfil (
Overlay.PROFILE) usava o mesmo estado de "overlay ativo" de tudo mais emApp.tsx— abrir o Perfil a partir de "+ Nova sala" (Overlay.ADD_ROOM) substituía esse overlay, e o botão "Voltar" do Perfil sempre fechava tudo, perdendo o fluxo de adicionar sala em andamento. Corrigido guardando o overlay anterior numa ref (overlayBeforeProfileRef) antes de abrir o Perfil, restaurado no "Voltar" em vez de fechar incondicionalmente.
Configurar sala pessoal saiu de Contatos, foi pra Configurações (v0.36.47+)
- ID/senha/toggle "abrir automaticamente" da sala pessoal, antes espalhados entre
ContactsScreen/PersonalRoomCard.tsx(Contatos) ePersonalRoomSettings.tsx(Configurações), viraram uma coisa só emPersonalRoomSettings.tsx, dentro da categoria "Sala pessoal e convites" de Configurações.PersonalRoomCard.tsxfoi deletado. Motivo do usuário: "configurar" pertence a Configurações, não a Contatos. - Botão "Abrir minha sala pessoal" continua na tela inicial (
PersonalIdPanel.tsx, dentro deHomeExtras), ao lado de um novo botão "Configurar sala pessoal" que abre Configurações já na categoria certa — decisão explícita do usuário de manter a ação de abrir separada da tela de configurar. SettingsScreenganhouinitialCategory?: SettingsCategory— permite abrir Configurações já na aba desejada.App.tsxguarda qual categoria abrir a seguir (settingsInitialCategory) e decide antes de abrir o overlay: o ícone de Configurações da barra de título sempre reseta praAUDIO, o botão novo da tela inicial (e o aviso de "sala pessoal sem senha") forçaCONTACTS.- Bug real encontrado e corrigido nesse meio tempo:
usePersonalRoom()não era reativo entre instâncias. Duas telas (PersonalIdPanelna tela inicial, sempre montada, ePersonalRoomSettingsem Configurações) usam o hook ao mesmo tempo — como cada uma guardava sua própria cópia emuseStatesem nenhum canal de sincronização, mudar a senha em Configurações não refletia na tela inicial (o botão "Abrir minha sala pessoal" continuava vendo a senha antiga, vazia, e recusava abrir). Corrigido:personalRoomPreference.tsganhou umEventTargetpróprio (subscribeToPersonalPassword/subscribeToPersonalAutoOpenEnabled, mesmo padrão denamePreference.ts) eusePersonalRoom()passou a usaruseSyncExternalStorepra senha e auto-abrir — só oid(nunca muda depois de gerado) continua emuseStatesimples. Ver.claude/rules/bug-history-ui.mdsobre "duas instâncias do mesmo hook sem canal de sincronização". ContactsScreen.tsxficou só com nome/avatar + lista de contatos — semfindSessionByRoomCode/autoFocusPersonalPassword, que só existiam pra sustentar o card de sala pessoal removido de lá.PreRoomProps/AddRoomOverlayPropsperderam esses dois campos e ganharamonOpenPersonalRoomSettings.
Endurecimento de convenções e segurança (v0.36.40+)
ContactRow.tsxvirou pasta (components/ContactsScreen/ContactRow/), seguindo o mesmo padrão já usado emRoom/JoinRequestModal/:ContactRow.tsxé só o decisor com um únicoreturn(exibição vs. edição),ContactRowView.tsxeContactRowEditForm.tsxsão os dois estados visuais distintos,ContactRow.types.tsconcentra os três tipos de props compartilhados,index.tsfaz o barrel export.- Formulários de contato (adicionar e editar) migraram de
useStatepor campo para React Hook Form + Zod, mesmo padrão já usado emCreateRoomForm.tsx:ContactForm.schema.ts(novo) define o schema{ name, id, password }compartilhado porAddContactForm.tsxeContactRowEditForm.tsx, evitando duplicar validação entre os dois. - Novo token Tailwind nomeado substituindo valor arbitrário:
spacing.unread-badge-min-width(badge de mensagens não lidas doRoomSwitcher, ver tamanho atual na seção de legibilidade abaixo).CheckboxFilterGroup.tsxpassou a reaproveitar o tokenform-columnjá existente em vez de um valor arbitrário duplicado. O token novo foi espelhado emlib/tv.ts(ver.claude/rules/bug-history-ui.mdsobre por que isso é obrigatório). - CSP adicionada em
renderer/index.html: restringeconnect-srcao broker PeerJS (0.peerjs.com, http/ws), STUN do Google, e às duas origens do GitHub usadas pra buscar changelog/release notes (raw.githubusercontent.com,api.github.com);img-src/font-src/script-srcrestritos a'self';style-srcmantém'unsafe-inline'(necessário pro Tailwind/React injetarem estilo inline). main/contacts.tsvalida o formato do contato antes de gravar emaddContact/updateContact(mesmoisContact()já usado emreadContacts()) — antes só a leitura filtrava entradas malformadas, a escrita confiava cegamente no que chegava pelo IPC.
Piso de legibilidade de fonte, sem depender de escala do app (v0.36.41+)
Primeira etapa de um plano maior de reflow/responsividade da interface (containers fixos, botões grandes de mais, texto pequeno de mais em janelas menores). Levantamento completo em docs-site/tecnico/mapa-do-codigo.md (esta seção) documenta só o que foi entregue; a etapa de containers fluidos e reflow de linhas densas ainda não foi feita.
- Tokens
fontSize.badge-xs(12.5px) efontSize.badge-xxs(10px) foram removidos. Levantamento mostrou que praticamente todo uso debadge-xsera texto funcional de verdade (conteúdo de botão inteiro, paginação, tooltips, versão do app, indicador de push-to-talk), não decoração — abaixo do piso legível na escala padrão do app, obrigando a aumentar a escala geral (Configurações → Interface) só pra conseguir ler. Todos os usos detext-badge-xsviraramtext-body-xs(13px, já existente). - Badge de mensagens não lidas do
RoomSwitcheraumentado de 16px/10px pra 20px/13px (spacing.unread-badge-min-widthde 16px → 20px, fonte debadge-xxsremovido →body-xs) — o número de não lidas e o "×" de sair da sala eram o texto mais pequeno de todo o app.
ActionButton ganhou tamanho compacto, pra linhas densas (v0.36.42+)
ActionButton.variants.tsganhou a variantesize('default' | 'compact'), junto da já existentevariant.compactreduz padding/margem (px-2 py-1 mr-0.5 my-0.5) e fonte (text-body-xs), mantendovariant='default'como padrão pra não afetar nenhum botão que já não declaravasizeexplicitamente. Botões de ação principal (Entrar, Salvar sala, confirmar atualização, etc.) continuam semsize, no tamanho de sempre.- Aplicado nos contextos de lista densa identificados no levantamento de responsividade:
ContactRowView/ContactRowEditForm(3 botões + nome numa linha só, dentro do card estreito de Contatos),NotificationEntryRow,HotkeyRecorderRow,ParticipantTile(que antes só compensava comtext-badge-xsmanual noclassName, sem reduzir o padding — trocado pela variante formal),InviteContactsPanel. - Modais de confirmação com par de botão (
UpdateReadyModal,PersonalRoomPasswordMissingModal,ReleaseSwitchConfirmation) ficaram de fora de propósito — são ação principal, não linha de lista, e o container já dá espaço de sobra pros dois botões lado a lado.
Voz simultânea em todas as salas, preview de compartilhamento persistente, indicador de quem está te assistindo (v0.36.46+)
- Voz deixou de ser exclusiva da sessão em foco.
useRoomSessions.tsnão chama maispauseVoice()/resumeVoice()ao trocar de foco entre salas — esses dois métodos foram removidos deRoomClient.tspor ficarem sem nenhuma chamada. Voz já começava automaticamente emcreateRoom()/joinRoom()independente de foco; o único código que a interrompia era esse par de chamadas nofocus()/leave()do hook. Resultado: agora dá pra ouvir e falar com todo mundo de todas as salas abertas ao mesmo tempo, não só da sala em foco. Mutar o microfone continua por sala (cadaRoomClient/VoiceChatjá era uma instância própria por sessão, isso não mudou). VoiceAudioSinks(os elementos<audio>que tocam a voz de quem chega) saiu de dentro deRoom.tsxpra um componente novo,components/RoomVoiceSink.tsx, renderizado emApp.tsxuma vez por sessão entrada, sempre — não só a que está em foco. Antes, comoApp.tsxsó monta o<Room>completo da sessão focada, o<audio>de uma sala em segundo plano nunca existia, então mesmo que a chamada de voz continuasse aberta não tinha nada tocando. Motivo de ser um componente à parte (não só mover o JSX pra dentro deApp.tsx): cada sessão precisa da própria assinatura deuseMembers/useDeafened, então virou um componente conectado próprio.silenciar todos(deafen) saiu deuseStatelocal doRoom.tsxpra estado do próprioRoomClient(isDeafened,deafened/setDeafened()/toggleDeafen(), eventodeafened-changed), mesmo padrão já usado pormicMuted/mic-muted-changed. Motivo duplo: (1) precisa ser a mesma fonte de verdade lida tanto peloRoomVoiceSink(sempre montado) quanto peloRoom.tsx(só quando em foco); (2) umuseStatelocal resetava prafalsetoda vez que você saía e voltava pra uma sala (oRoomdesmontava), então "silenciar" não durava a troca de foco.voiceAudioState(volume/mudo por participante, ajustado na barra lateral) passou a viver por sessão emuseRoomSessions.ts(RoomSession.voiceAudioState, criado uma vez porcreateSession()viacreateMemberAudioStateStore()— a mesma fábrica queuseMemberAudioState()usa por baixo, extraída pra poder ser chamada fora de um componente React) em vez de ser recriado a cada montagem doRoom.tsx. Efeito colateral bem-vindo: o volume que você ajustou pra um participante não reseta mais só por trocar de sala e voltar.- Preview de "Você está compartilhando sua tela" ficava preto/quebrado ao trocar de sala e voltar — causa raiz:
localStream(a referência aoMediaStreamsendo compartilhado) vivia emuseStatelocal doShareControls.tsx, que desmonta junto com o resto doRoomda sessão que perde o foco. A captura de tela em si nunca parava (os outros participantes continuavam vendo normal), só a UI local perdia a referência. Corrigido expondolocalStreamcomo estado do próprioRoomClient/MediaSharing(MediaSharingjá guardava isso; só faltava emitir um eventolocal-stream-changednas três operações que o alteram —start/replaceStream/stop— e um hookuseLocalStream(roomClient)pra ler), sobrevivendo à troca de foco igual o resto do estado de sala. - Indicador visual de "está assistindo sua tela" no
ParticipantTile, ao lado do ícone de microfone mutado: usa ooutgoingCallsqueMediaSharingjá mantinha internamente (exposto comogetViewerIds()), com um hook novo (useViewerIds) assinando os eventosviewer-added/viewer-removedque já existiam (usados até então só pra tocar som). Só aparece quando você está compartilhando (mySharingActive) — sem sentido mostrar "assistindo" quando não tem nada sendo compartilhado. - Bug real na primeira versão desse
getViewerIds()(corrigido antes de chegar a uma release estável): retornava um array novo ([...outgoingCalls.keys()]) a cada chamada, em vez de uma referência cacheada comogetMembersSnapshot()já fazia.useSyncExternalStore(por trás deuseViewerIds) exige quegetSnapshot()devolva a mesma referência entre uma notificação de mudança e outra — devolver uma referência nova toda vez engana o React fazendo achar que a store mudou a cada render, o que disparou o erro real do React "Cannot update a component while rendering a different component" bem no meio da trocaPreRoom → Roomao criar uma sala, quebrando a criação de sala inteira (só descoberto rodando a suítee2e/de verdade — nenhuma checagem estática pega esse tipo de bug). Corrigido cacheandocachedViewerIdsemMediaSharing, recalculado só quandooutgoingCallsmuda de verdade, mesmo padrão decachedMembersSnapshot.
Paginação menor e card de compartilhamento não quebra mais em janela estreita (v0.36.44+)
LOGS_PAGE_SIZEeNOTIFICATIONS_PAGE_SIZE(constants/pagination.ts) caíram de 15 pra 5 — 15 itens por página deixava as telas de Logs e Notificações compridas de mais.RELEASE_HISTORY_PAGE_SIZE/CHANGELOG_PAGE_SIZE(10) não mudaram, não foram reportados como problema.ShareActiveStatus.tsx(card "Você está compartilhando sua tela") corrigido pra não vazar pra fora do card em janela estreita. Duas causas raiz combinadas: (1) o bloco de texto usavamin-w-form-column(180px) — um token pensado pra coluna de formulário, reaproveitado indevidamente aqui, forçando uma largura mínima que não cabia no espaço real da barra lateral de compartilhamento; viroumin-w-0combreak-wordsno texto de status. (2) o preview de vídeo tinha tamanho fixo (w-self-preview-width h-self-preview-height, 160×90px) sem conseguir encolher — virouw-full max-w-self-preview-width h-auto, mantendo o teto de 160px mas encolhendo quando o espaço disponível é menor, sem forçar proporção fixa (ver.claude/rules/bug-history-capture-and-audio.mdsobre nunca forçaraspect-videonum preview de captura de tela).- Causa mais profunda, em
Room.tsx: o container central (RoomStage) não tinhamin-w-0. Item flex sem esse token nunca encolhe além do tamanho mínimo do próprio conteúdo (comportamento padrão do flexbox) — como o conteúdo interno (ShareActiveStatus) tinha componentes de largura mínima fixa maior que o espaço disponível, o container inteiro recusava encolher, empurrando o layout de sala inteiro e forçando rolagem horizontal. Adicionadomin-w-0no container.
Containers fluidos e colapso de barra lateral (v0.36.43+)
Terceira etapa do plano de reflow/responsividade. Escopo contido ao que o levantamento mostrou como problema real (largura fixa causando aperto visível), não uma reescrita geral de todo container fixo do app — max-w-modal (360px), por exemplo, ficou como estava: é usado tanto em diálogos pequenos quanto em painéis de Configurações/Atualizações, e nos dois casos 360px é um teto razoável pra não deixar campo de formulário/select esticado de mais, mesmo em janela larga.
maxWidth.contacts-screendeixou de ser um valor fixo (760px) e virou fluido:min(94vw, 900px). É o token usado porContactsScreen.tsxePreRoom.tsx, os dois únicos lugares do app com grid de duas colunas (md:grid-cols-2) — com o teto fixo em 760px, cada coluna ficava sempre em ~370px independente de a janela ser muito mais larga que isso (era a causa do card de contato raso reportado, nome cortado em "A.."). Continua limitado (não estica indefinidamente em monitor ultrawide) mas agora acompanha a largura real da janela até 900px.SettingsScreen.tsxeUpdatesScreen.tsxganharam colapso responsivo na barra lateral fixa: containers viraramflex-col md:flex-row(empilha verticalmente abaixo do breakpointmd, volta a ser duas colunas lado a lado a partir dele), e a barra lateral (w-form-column/w-room-sidebar-width, fixas em 180px/260px) virouw-full md:w-form-column/w-full md:w-room-sidebar-width— em janela estreita as abas de categoria/navegação passam a ocupar a largura toda como uma faixa horizontal com quebra de linha (flex-row flex-wrap) em vez de forçar uma coluna fixa competindo por espaço com o conteúdo.
Configurações por categoria, editar contato, chamar só quem está online (v0.36.38+)
SettingsScreen.tsxganhou um menu de categorias na lateral (constants/settingsCategory.ts:AUDIO/SOUNDS/INTERFACE/HOTKEYS/CONTACTS) em vez de empilhar todos os blocos de configuração verticalmente numa lista só — mesmo padrão visual de configurações de jogo. Cada categoria só monta os componentes daquele grupo (AudioOutputSettings+MicInputSettingsemAUDIO, etc.), sem re-render dos outros.PersonalRoomSettings.tsx(novo): o checkbox "Abrir automaticamente" saiu doPersonalRoomCard.tsx(tela de Contatos) e virou seu próprio componente na categoriaCONTACTSde Configurações, ao lado deInviteSettings.tsx— mesmo hook (usePersonalRoom()), só mudou de tela.PersonalRoomCard.tsxficou só com ID/senha/botão de abrir.- Editar contato salvo, sem remover e recriar: novo canal IPC
updateContact(main/contacts.ts,updateContact(originalId, contact)— remove a entrada com o id antigo E qualquer entrada que já tivesse o novo id, depois grava a nova, cobrindo tanto "só mudei o apelido" quanto "a pessoa reinstalou e o ID dela mudou").ContactRow.tsxganhou um modo de edição inline (nome/ID/senha pré-preenchidos, Salvar/Cancelar) reaproveitando os mesmos campos doAddContactForm.tsx. - Não dá mais pra clicar em "Chamar" de um contato confirmado offline:
ContactRow/RecentContactsPaneldesabilitam o botão quandouseContactsPresence(...).get(id) === false(só quando a sondagem já confirmou offline — enquanto está "verificando", o botão continua clicável, pra não travar a UI numa checagem que pode demorar). Motivo: antes disso, tentar chamar alguém offline sempre falhava com um erro genérico ("Não foi possível conectar") sem nenhum aviso prévio, já que o app já sabia (pela bolinha de presença) que a pessoa estava offline.
Notificação de contato online (v0.36.37+)
services/contactsPresenceStore.ts — um singleton (EventTarget, sem estado do React) que substitui as sondagens avulsas que ContactsScreen/OnlineContactsPanel faziam cada uma por conta própria. Motivo: rodar continuamente em segundo plano, independente de qual tela está aberta, é o que permite avisar "fulano ficou online" mesmo com a tela de Contatos fechada.
App.tsxchamacontactsPresenceStore.start()uma vez (nunca para, já queAppnunca desmonta) esetContactIds()toda vez que a lista de contatos muda. Isso disparawatchContactsPresence(o mesmo sondador de sempre,services/contactPresence.ts) a cadaCONTACT_PRESENCE_POLL_INTERVAL_MS(60s).- A cada resultado, a store compara com o valor anterior (
wasOnline === false) — só nesse caso específico (offline confirmado → online) dispara o eventocontact-online. Isso evita notificar sobre todo mundo que já estava online quando o app abriu (não tem "valor anterior" pra comparar nesse caso,wasOnlineéundefined). - A notificação é automaticamente bidirecional, sem nenhum código extra pro "outro lado". Se a Pessoa B também salvou a Pessoa A como contato, o app da Pessoa B está rodando esse mesmo
contactsPresenceStoresondando a Pessoa A — quando a sala pessoal da Pessoa A fica alcançável, é o app da PRÓPRIA Pessoa B que detecta a transição e notifica, de forma completamente independente. Não existe (nem poderia existir, sem servidor) um "aviso" indo de A pra B — cada lado descobre sozinho. hooks/useContactsPresence.ts(useSyncExternalStore+onTyped, mesmo padrão de sempre pra ligar um serviçoEventTargetao React) é a única porta de entrada pra UI ler o mapa de presença —ContactsScreeneOnlineContactsPanelforam migrados pra usá-lo em vez de rodarwatchContactsPresencecada um por conta própria, então agora existe uma única sondagem ativa, não três.
Painel da tela inicial (v0.36.35+)
components/PreRoom/HomeExtras/ — cinco cards lado a lado com PreRoomChoice (grid de 2 colunas em PreRoom.tsx, max-w-contacts-screen igual ContactsScreen), preenchendo o espaço vazio da tela inicial com informação real, não decoração:
PersonalIdPanel.tsx: reaproveitausePersonalRoom()+CopyButton, mesmo padrão do card "Sua sala pessoal" em Contatos.OnlineContactsPanel.tsx: reaproveitawatchContactsPresence(o mesmo serviço de sondagem de presença da tela de Contatos) pra contar quantos contatos estão online, sem duplicar lógica.RecentContactsPanel.tsx:services/recentContactPreference.ts(localStorage, atéRECENT_CONTACTS_MAXIDs, mais recente primeiro) grava um contato toda vez queContactsScreen.handleCallContactchama com sucesso — o painel lê essa lista e permite chamar de novo direto da tela inicial, sem abrir Contatos.ChangelogTeaserPanel.tsx: reaproveitauseMarkdownFile('CHANGELOG.md')+parseSimpleMarkdown(o mesmo hook que já alimenta a aba Changelog da tela de Atualizações — busca do GitHub raw, não duplica o conteúdo) e mostra só a primeira linha da tabela (versão mais recente).RetroClockTipPanel.tsx: relógio atualizado a cadaCLOCK_TICK_INTERVAL_MSe uma dica deHOME_STRINGS.tipsescolhida pordia do ano % tamanho da lista— estável o dia inteiro, muda sozinha no dia seguinte, sem precisar de estado persistido.
onOpenUpdates (abre Overlay.UPDATES) é passado de App.tsx pros dois pontos que renderizam <PreRoom> (o principal e o de dentro de AddRoomOverlay), pra o botão "Ver tudo que mudou" funcionar nos dois fluxos.
Onde cada preferência mora
Duas fontes de armazenamento persistente, cada uma por um motivo técnico específico:
- Volume dos sons, escala, dispositivos →
localStoragedo renderer, porque só o processo de UI precisa desses valores. - Atalhos de teclado, flags, auto-update →
settings.json(viamain/settings.ts), porque mic/áudio dependem doglobalShortcutdo main, e é o main que decide se ativa flags do Chromium.
A lista de contatos foge desse padrão de propósito: mora num arquivo próprio, contacts.json (via main/contacts.ts), em vez de localStorage. É uma escolha deliberada, não técnica — é uma lista pequena que o usuário entende como "meus dados salvos", igual logs.jsonl/settings.json, mais fácil de inspecionar ou fazer backup manualmente do que algo preso dentro do perfil do Chromium.
Contatos e sala pessoal
Duas peças novas, pensadas pra não mexer em nada do protocolo de sala existente:
- ID pessoal (
services/personalRoomPreference.ts): gerado uma vez por instalação (localStorage, nãocontacts.json— é um dado do PRÓPRIO usuário, não uma lista de terceiros) e usado comodesiredCodeao chamarRoomClient.createRoom(name, password, avatarId, desiredCode)— um parâmetro novo, opcional, que faz o método pular o loop de gerar umrandomRoomCode()e usar esse valor fixo direto. Na prática, sua "sala pessoal" é uma sala normal, só que sempre com o mesmo código. - Contatos (
contacts.json,hooks/useContacts.ts): cada contato é{ id, name, password }— oide a senha são exatamente o que a outra pessoa mostra na própria tela de Contatos, trocados manualmente uma vez (fora do app, por WhatsApp/Discord/etc). "Chamar" um contato é literalmenteRoomClient.joinRoom(name, contact.id, contact.password, avatarId)— a mesma função que "Entrar numa sala" usa, só que os campos já vêm preenchidos.
"Chamar" um contato ainda passa pela aprovação manual de entrada, igual qualquer sala — quem chama ainda precisa que o dono da sala pessoal aprove manualmente. Nunca existiu (e não existe) um caminho de "auto-aprovar" só porque o peer ID bate com um contato salvo — isso enfraqueceria a mesma proteção que toda sala já tem (senha errada precisa ser silenciosa, nunca revelar se alguém "quase" acertou).
Convidar contatos pra uma sala nova (pula a aprovação — v0.36.25+)
Pedido explícito do usuário: ao criar uma sala nova, dá pra marcar contatos salvos pra convidar direto — sem precisar mandar código/senha por fora, e sem precisar aprovar manualmente quando a pessoa entrar. Isso É uma exceção deliberada à regra acima, mas construída com cuidado pra não abrir brecha nenhuma:
- Como o convite chega:
PeerConnectionManager.sendInvite(peerId, message)abre uma conexão PeerJS avulsa (fora do fluxo normal de membro/mesh — não passa porregisterDataConnection, então não dispara ohelloautomático desse lado), manda uma mensagem{ type: 'invite', roomCode, roomPassword, inviteToken, hostId, hostName, hostAvatarId }e fecha. Do lado de quem recebe, essa mensagem é interceptada dentro do handler de'data'já existente (antes de virar umRoomMessagenormal) e desviada pra um deponInviteMessage, nunca chega noRoomProtocol.handleMessagecomum. - Verificação mútua obrigatória: quem recebe só mostra o convite se
hostId(o ID pessoal ESTÁVEL de quem convidou, não o peer ID efêmero da sala nova) estiver na PRÓPRIA lista de contatos de quem recebeu (RoomClient.handleInviteMessageconfere viawindow.api.getContacts()). Ou seja: só aceita convite de alguém que EU também salvei como contato — evita popup de estranho que descobriu meu ID pessoal por acaso. - Por que um token, e não só "peer ID pré-autorizado": a primeira versão dessa ideia tentava pré-autorizar o
contact.id(o ID pessoal do convidado) direto emRoomAuthController. Não funciona:joinRoomsempre abre a conexão com um peer ID novo e aleatório (this.connections.open(undefined, ...)), nunca com o ID pessoal — então o pedido de entrada que chega de verdade nunca bate com o ID que foi pré-autorizado. A solução foi trocar por um token de uso único:RoomAuthController.preAuthorizeToken(token)guarda o token gerado na hora do convite; o convidado carrega esse token junto dohello(helloMessageSchema.inviteToken, opcional) quando entra;handleJoinRequestconfere o token (não o peer ID) e, se bater, aprova igualapproveJoinRequestfaria — sem nunca colocar na fila (pendingJoinRequests) nem mostrar oJoinRequestModal. - Limitação aceita: só dá pra convidar um contato que esteja com a sala pessoal DELE aberta no momento — é a única forma de alcançar o peer ID dele sem servidor de presença (mesma limitação de "Chamar", só que na direção contrária).
Sala pessoal abrindo sozinha (v0.36.26+)
useRoomSessions.ts tem um useEffect de bootstrap (guardado por um ref pra rodar só uma vez, mesmo sob <StrictMode>) que, ao montar, chama createSession() + RoomClient.createRoom(...) automaticamente pra sala pessoal, só se já tiver senha configurada E getPersonalAutoOpenEnabled() estiver ligado (v0.36.28+, ligado por padrão — checkbox no PersonalRoomCard.tsx), com desiredCode = getPersonalId().
Essa sessão entra na lista normal de sessions (aparece no RoomSwitcher assim que conecta), mas não rouba o foco — a tela de escolha (pendingSession) continua aparecendo normalmente pro usuário. RoomSwitcher.tsx (v0.36.27+) e RoomHeader.tsx (v0.36.28+, mesma etiqueta só que DENTRO da sala, perto do código/senha) usam services/roomKind.ts (getRoomKind(roomCode)) pra comparar roomCode contra getPersonalId() e mostrar o selo de casinha — comparação simples porque o código da sala pessoal é sempre esse ID fixo.
Removida na v0.36.30: a "sala automática" (segunda sala fixa, opt-in, que convidava sozinha uma lista de contatos por polling a cada ~30s). Decisão explícita do usuário: não fazia sentido ter uma sala separada dedicada só a isso — o pedido virou "convidar contato pra QUALQUER sala já aberta, na hora, manualmente" (ver seção "Convidar contato de dentro da sala" abaixo). Removidos: AutoRoomCard.tsx, useAutoRoom.ts, useAutoInvite.ts, autoRoomPreference.ts, constants/autoRoom.ts, o branch 'auto' de getRoomKind, o selo de raio no RoomSwitcher. O que NÃO foi removido (continua genérico, reaproveitado pela nova forma manual de convidar): RoomClient.inviteContact(contact), hasContactJoinedViaInvite(contactId), preAuthorizeToken/token de convite, todo o protocolo invite — nada disso era exclusivo da sala automática.
Convidar contato de dentro da sala (v0.36.30+)
Botão novo no RoomToolbar.tsx (ícone de pessoas, ao lado de mic/áudio) abre/fecha um InviteContactsPanel.tsx inline no RoomSidebar, listando os contatos salvos com um botão Convidar por linha — chama roomClient.inviteContact(contact) direto (mesmo mecanismo de token pré-autorizado que já existia, sem código/senha). Pra cada contato, o painel mostra "Já está na sala" se member.personalId === contact.id bater com algum membro atual, ou "Convite enviado" (estado local do painel + hasContactJoinedViaInvite) se já foi convidado e ainda não entrou. Funciona em qualquer sala já aberta — pessoal ou normal — diferente do checklist de CreateRoomForm.tsx, que só convida na hora de CRIAR a sala.
Expiração, falha de envio e convites de desconhecidos (v0.36.31+)
- Token de convite expira em 5 minutos (
INVITE_TOKEN_TTL_MSemconstants/timing.ts):RoomAuthController.preAuthorizeToken(token)agenda a própria remoção do token viasetTimeout, einvalidateToken(token)permite invalidar cedo (usado quando o envio falha). Do lado de quem recebe,useRoomSessions.tsremove a entrada dependingInvitespelo mesmo TTL — os botões Aceitar/Recusar (no popup e na tela de Notificações, que já derivam dependingInvites) somem sozinhos depois de 5 minutos, sem precisar de nenhuma lógica extra na UI. PeerConnectionManager.sendInviteagora detecta falha de entrega: timeout deINVITE_SEND_TIMEOUT_MS(8s) maisconnection.on('error', ...)disparamdeps.onInviteSendFailed(id).RoomClientpropaga isso como o eventoinvite-send-failed;useRoomSessions.tsresolve o nome do contato (window.api.getContacts()) e chamanotifyUser(NotificationKind.INVITE_FAILED, ...).InviteContactsPanel.tsxtambém reflete a falha por contato (failedContactIds, propagado deRoom.tsxaté o painel) trocando "Convite enviado" por "Falhou" + botão "Tentar de novo".- Convite pra quem não é contato, com aviso pra quem convidou: novo tipo de mensagem no protocolo,
invite-rejected(roomMessage.schema.ts,RoomProtocol.ts), trocado na mesma conexão avulsa do convite (não passa peloRoomProtocol.handleMessagenormal). Antes,PeerConnectionManagerfechava a conexão de convite imediatamente depois de repassar a mensagem proRoomClient; agora quem decide fechar éRoomClient.handleInviteMessage, porque a decisão passou a ser assíncrona — depende de checarwindow.api.getContacts()ewindow.api.getAllowUnknownInvites(). Se o remetente não for contato salvo e a configuração estiver desligada, o destinatário responde{ type: 'invite-rejected', reason: 'unknown-sender' }e fecha; quem convidou recebe isso viadeps.onInviteRejected→ eventoinvite-rejected→ notificação explicando o motivo. - Configuração nova,
allowUnknownInvites(main/settings.ts, defaulttrue): mesmo padrão dos outros booleanos desettings.json(getSettings()/setter dedicado, canal IPC próprio emipcChannels.ts, exposto empreload/index.ts). UI emcomponents/SettingsScreen/InviteSettings.tsx, hookuseAllowUnknownInvites.ts.
Status online dos contatos (v0.36.31+)
services/contactPresence.ts (watchContactsPresence(contactIds, onResult)) faz uma sondagem de presença: abre um Peer novo e descartável (mesmo createPeer de peerSession.ts) e tenta peer.connect(id, ...) pra cada contato — connection.on('open', ...) significa online, timeout (CONTACT_PRESENCE_PROBE_TIMEOUT_MS) ou error significa offline. É o mesmo mecanismo de baixo nível que "Chamar"/convite já usam pra alcançar alguém, só que sem completar o handshake de sala. ContactsScreen.tsx chama isso a cada mudança na lista de contatos e repassa um Map<contactId, boolean> pra ContactRow.tsx, que mostra uma bolinha verde/cinza/pulsando (online/offline/verificando).
Trocar senha sem reabrir a sala
RoomClient.setPassword(password) (método novo, só atualiza this.currentPassword) existe porque agora a sala pessoal/automática ficam abertas o tempo inteiro — mudar a senha em Contatos precisa refletir na sessão já viva. useRoomSessions.ts expõe findSessionByRoomCode(roomCode) (usado por ContactsScreen.tsx pra achar a sessão certa e chamar setPassword nela) — esse helper precisou ser passado por prop através de PreRoom/AddRoomOverlay, já que ContactsScreen só recebia o roomClient da sessão pendente antes disso.
Sistema de notificações (v0.36.26+)
Separado da tela de Logs de propósito: Logs é auditoria técnica (todo evento, incluindo ruído de diagnóstico), Notificações é só o que interessa pro usuário como pessoa — convite recebido/aceito/recusado, membro entrou/saiu, pedido de entrada, atualização disponível. Mesma receita de contacts.json/logs.jsonl, com o extra de lida/não-lida:
- Main:
main/notifications.ts(espelhamain/logger.ts) grava emnotifications.jsonl, empurra pro renderer via IPC (notificationAdded) a cada entrada nova.shared/notificationEntry.tsdefineNotificationKind(enum),NotificationCategory(v0.36.28+, agrupa oskinds eminvites/room/systemvia o mapa fixoNOTIFICATION_KIND_CATEGORY— derivado em tempo de leitura, não persistido) eNotificationEntry { id, timestamp, kind, message, read }. - Renderer:
services/notifyUser.ts(equivalente aologEventde sempre) chamado nos mesmos pontos onde já se gera log técnico, mas só pros eventos que interessam ao usuário — a maioria vive dentro deuseRoomSessions.ts, junto dos listeners que já existiam pra tocar som (member-joined,invite-received, etc.). - Tela:
components/NotificationsScreen/espelhaLogsScreen/(lista + paginação + confirmação de limpar, mais exclusão individual por item e filtros de tipo/categoria — v0.36.28+/v0.36.30+). Acesso por um ícone de sino novo naTitleBar, com uma bolinha de não-lida contada viauseNotifications(). - Sincronização entre instâncias do hook (v0.36.28+):
useNotifications()é chamado em mais de um lugar ao mesmo tempo (contador do sino emApp.tsx+ a tela cheia) — cada chamada tem seu própriouseState. Pra qualquer mutação (markNotificationRead,markAllNotificationsRead,clearNotifications,deleteNotification— todos emmain/notifications.ts) refletir em TODAS as instâncias, o main emite um eventonotifications-changedcom a lista inteira atualizada (mesmo padrão denotificationAdded, só que pra mudança de estado em vez de criação), e o hook escuta esse evento viaonNotificationsChanged(novo empreload/index.ts/global.d.ts) — ver bug documentado noCLAUDE.mdlocal (badge do sino ficava preso). - Filtros dinâmicos em Logs e Notificações (v0.36.29+): as opções de cada filtro vêm de um
useMemoem cima dosentriesque já existem ([...new Set(entries.map(e => e.category))], mesma ideia pralevel/kind) em vez de listarObject.values(Enum)fixo — uma categoria/tipo sem nenhum registro simplesmente não aparece na lista de opções do filtro. - Filtros viraram checkboxes, não
<select multiple>(v0.36.30+): o multi-select nativo do navegador tinha um destaque de seleção azul que não dava pra restilizar com os tokens do tema retrô. Trocado porcomponents/CheckboxFilterGroup/(label + lista de<input type="checkbox">, mesmo padrão visual já usado no checklist de convite deCreateRoomForm.tsx), usado 2x emLogsScreen.tsx(tipo, categoria) e 2x emNotificationsScreen.tsx(tipo, categoria) —CategoryFilter.tsx(pílulas antigas de seleção única) elib/selectValues.tsforam removidos;lib/toggleArrayValue.tssubstitui a leitura deselectedOptionspor um toggle simples de array. - Convite recebido acionável na tela de Notificações (v0.36.30+): antes só dava pra aceitar/recusar pelo popup bloqueante (
InviteReceivedModal). Agora oinviteIdgerado emuseRoomSessions.ts(listenerinvite-received) é o MESMO usado tanto empendingInvitesquanto passado pronotifyUser(NotificationEntry.inviteId, novo campo opcional).NotificationsScreen.tsxcruzapendingInvitescomentry.inviteId— se ainda estiver pendente,NotificationEntryRow.tsxmostra os botões Aceitar/Recusar em vez do botão de excluir. Os handlers (handleAcceptInvite/handleDeclineInvite, com som) foram centralizados emApp.tsxe passados tanto pro modal quanto pra tela de Notificações, pra não ter dois lugares com lógica/som duplicados.
Aviso de sala pessoal sem senha e notificações clicáveis (v0.36.32+)
- Popup ao abrir o app:
App.tsxroda umuseEffectsó na montagem que checagetPersonalAutoOpenEnabled() && !getPersonalPassword()— se verdadeiro, mostraPersonalRoomPasswordMissingModal(mesmo padrão deUpdateReadyModal/InviteReceivedOverlay:fixed inset-0 z-50). Sem persistência de "já vi isso" — reaparece toda vez que o app abre, de propósito, até a senha ser definida. - Gate contra instalação nova/perfil limpo (v0.36.34, corrigido rápido): a condição inicial era só
getPersonalAutoOpenEnabled() && !getPersonalPassword()— como auto-abertura vem ligada por padrão e senha vem vazia por padrão, o popup aparecia em QUALQUER perfil novo (fixed inset-0 z-50), bloqueando a tela inicial inteira, inclusive "Criar sala nova" — pego porque quebrou a suítee2e/inteira (13/13 specs falhando emgetByText('Criar sala nova').click(), interceptado pelo próprio popup). Corrigido comhasPersonalRoomBeenConfigured()(personalRoomPreference.ts): só considera "configurado" sePERSONAL_AUTO_OPEN_STORAGE_KEYouPERSONAL_PASSWORD_STORAGE_KEYjá foram gravados alguma vez nolocalStorage— ou seja, o aviso só aparece depois que a pessoa já mexeu na sala pessoal (digitou senha ou tocou no checkbox) pelo menos uma vez, nunca no primeiro uso do app. - Lembrete diário na aba de Notificações: o mesmo
useEffectgrava a data de hoje (Date().toDateString()) empersonalRoomPreference.ts(getPersonalPasswordReminderLastShownDate/setPersonalPasswordReminderLastShownDate,localStorage) e só chamanotifyUser(NotificationKind.PERSONAL_ROOM_PASSWORD_MISSING, ...)se a data salva for diferente de hoje — garante no máximo uma notificação por dia, mesmo reabrindo o app várias vezes no mesmo dia (o popup continua aparecendo em toda abertura, só a notificação é limitada a 1x/dia). - Notificação clicável:
NotificationEntry/NotificationEntryRowganharam um proponNavigate: (entry) => void, chamado porhandleRead()(a mesma função que já marca como lida) pra qualquer notificação, independente do tipo.App.tsximplementahandleNotificationNavigatedecidindo peloentry.kind:PERSONAL_ROOM_PASSWORD_MISSINGchamaopenContactsScreen(true);UPDATE_AVAILABLEchamaopenOverlay(Overlay.UPDATES). Outros tipos (convite, membro entrou/saiu) não navegam — a função simplesmente não faz nada pra eles. - Foco automático no campo de senha: prop booleana nova (
focusContactsPassword/autoFocusPersonalPassword/autoFocusPassword, dependendo da camada) encadeada porApp.tsx→AddRoomOverlay→PreRoom→ContactsScreen→PersonalRoomCard, virandoautoFocusno<PasswordInput>(que já encaminharef/props pro<input>nativo viaforwardRef). Só funciona porque cada overlay é desmontado/remontado do zero ao fechar/abrir (activeOverlaytroca o que é renderizado emApp.tsx), então oautoFocusnativo dispara de novo a cada vez que a tela de Contatos é aberta por esse caminho.App.tsxsempre passa esse valor explicitamente (nunca deixa o padrão do parâmetro decidir) em todo ponto que abre Contatos, pra garantir que um clique normal no ícone de Contatos daTitleBarnunca herde um foco pendente de uma navegação anterior.
Sistema de sons
Ver services/soundEffects.ts na seção de serviços acima. Duas regras que não têm exceção:
- Toda ação clicável relevante tem um som próprio. Se você adicionar um botão/campo/transição novo, ele precisa de um
playXSound()novo. - Nenhum som é reaproveitado pra uma ação com significado diferente de outra que já tem som. A única exceção é a mesma ação semântica repetida em telas diferentes (ex: "Voltar" usa sempre
playBackButtonSound(), não importa em qual tela).
Eventos de compartilhamento/assistir ganharam som próprio de cada lado (v0.36.33+). Antes só existia som pra quem inicia/para de compartilhar (playShareStartSound/playShareStopSound). Cinco eventos novos no protocolo de sala, cada um com som distinto: RoomProtocol.handleSharingStatus detecta a transição sharing: false → true de um membro e chama deps.onMemberStartedSharing (novo em RoomProtocolDeps) → RoomClient dispatcha member-sharing-started, ouvido por todo mundo na sala (playMemberSharingStartedSound). Do lado de quem assiste: RoomClient dispatcha watch-started quando o stream chega de verdade (onIncomingStream) e watch-stopped tanto ao clicar "Parar de assistir" (toggleWatch) quanto quando o stream fecha sozinho (onIncomingStreamClosed, guardado por wasWatching pra não disparar duas vezes quando as duas coisas coincidem). Do lado de quem compartilha: MediaSharing.handleWatchRequest/handleUnwatchRequest dispatcham viewer-added/viewer-removed, relayados por RoomClient, com sons DIFERENTES dos de quem assiste (playViewerJoinedWatchingSound/playViewerLeftWatchingSound), porque são pessoas diferentes reagindo ao mesmo evento.
Toda notificação nova também tem som ao chegar, não só ao ler (v0.36.32+). Auditoria encontrou dois pontos de notifyUser() sem som nenhum (convite falhou/recusado, atualização disponível) — corrigido adicionando playInviteFailedNotificationSound()/playUpdateAvailableNotificationSound() nos mesmos pontos que já chamavam notifyUser, seguindo o padrão que playInviteReceivedSound()/playMemberJoinedSound() já usavam. O som de "conectado com sucesso" (playJoinedRoomSound) foi trocado nessa mesma versão: usava dial-up-internet.mp3 (efeito de internet discada de verdade, ~30s — longo demais pra feedback de UI), substituído por um bipe curto de três notas sintetizado (room-joined-connect.wav, ~0.2s) que mantém o espírito nostálgico (bipes tipo handshake de modem) sem o incômodo.
Cada som pertence a uma categoria (SoundCategory em constants/soundEffects.ts: Interface, Sala, Chat, Chamada de voz, Compartilhamento), e o usuário controla o volume de cada categoria separadamente na tela de Configurações (mixer). Os arquivos .wav em assets/sounds/ são sintetizados por script (tons puros/triangulares/quadrados/dente-de-serra com envelope de ataque/liberação) — não baixados de banco de som externo, mantendo a filosofia de não depender de rede além do broker PeerJS.
Múltiplas salas ao mesmo tempo
Como RoomClient não guarda nenhum estado global/singleton (cada new RoomClient() tem seu próprio PeerConnectionManager, VoiceChat, MediaSharing, ChatService), o app consegue ter várias instâncias vivas em paralelo. useRoomSessions.ts gerencia a lista (RoomSession[], cada uma com { sessionId, roomClient, roomCode, unreadCount }) e qual está em foco. Voz é simultânea em todas as sessões abertas desde a v0.36.46 (mic e chamadas de voz continuam ativos independente de foco, App.tsx monta um RoomVoiceSink por sessão entrada, não só a focada). Só vídeo (assistir tela de alguém) continua exclusivo da sessão em foco — só uma sala de vídeo renderizada por vez, de propósito.
Teclas de atalho e push-to-talk
Duas mecânicas diferentes, escolhidas pelo que cada ação realmente precisa:
- Mutar mic / mutar áudio (
main/toggleHotkeys.ts): só precisam saber "a tecla foi apertada", não quando foi solta — por isso usamglobalShortcut.register(accelerator, callback), a API nativa do próprio Electron, sem dependência externa. Funcionam com qualquer programa em foco, inclusive um jogo em tela cheia. - Push-to-talk (
renderer/src/hooks/usePushToTalk.ts+main/globalHotkeys.ts): precisa saber quando a tecla é solta, pra cortar o mic. Desde a v0.36.25 é híbrido — dois caminhos escutados ao mesmo tempo, qualquer um dispara o PTT:- DOM (
keydown/keyupno renderer, mesmolib/keyboardAccelerator.tsque mic/áudio já usavam) — só funciona com o ZeroHop em primeiro plano, mas é 100% confiável (sem dependência nativa nenhuma). uiohook-napi(main/globalHotkeys.ts, captura de teclado em nível de sistema) — se conseguir iniciar e entregar eventos, funciona mesmo sem foco. Histórico: já foi a única forma de PTT até a v0.36.20, removida na v0.36.21 (trocada pornode-global-key-listener, que também falhou), removida de vez na v0.36.22 (só DOM/foco) depois de FALHAR TRÊS VEZES na mesma máquina de um usuário — inclusive uma vez com o antivírus (ESET) desativado, descartando antivírus como única causa. Reintroduzida na v0.36.25 como tentativa em paralelo, não substituição: decisão explícita do usuário, ciente do histórico, porque o caminho DOM garante que nunca fica pior do que já estava (só sem foco é que pode ganhar, nunca perde o que já funcionava).
- DOM (
A gravação da tecla de PTT usa exatamente o mesmo lib/keyboardAccelerator.ts que mic/áudio já usavam — não existe mais um formato de tecla separado (HotkeyBinding/keycode sumiu, tudo é AcceleratorBinding agora). Push-to-talk reaproveita o mecanismo de mute normal (RoomClient.setMicMuted()) em vez de um estado paralelo, pra manter o indicador visual e o som de mutar sempre corretos. A configuração de atalhos ainda mora em settings.json (não localStorage), porque mic/áudio continuam dependendo do main (globalShortcut) — o main avisa o renderer de qualquer mudança via o canal hotkeySettingsChanged, pra usePushToTalk sempre ler a tecla mais recente mesmo com a tela de Configurações aberta por cima da sala.
Segurança do processo principal
contextIsolation: true,nodeIntegration: false— o renderer nunca tem acesso direto ao Node.js.setPermissionRequestHandlersó libera a permissãomedia(câmera/microfone) — qualquer outra permissão do navegador é negada.- Dependências nativas (
.node) sempre carregadas comimport()dinâmico dentro de uma função, nunca no topo do arquivo — umrequire/importestático de uma lib nativa ausente derruba o processo principal inteiro antes de qualquer verificação de configuração acontecer (já aconteceu neste projeto).