Skip to content

Agent reference: Chat Panel

Deep subsystem reference for AI assistants. Open this when a task touches the Chat panel, composer, link previews, notifications, dedup, hop badges, reactions/tapbacks, mentions, or chat/support export. Hard rules live in AGENTS.md.

  • Components: ChatPanel.tsx (channel/DM UI) + shared ChatComposer.tsx (drafts, mentions, chunking, spellcheck, emoji; also used by RoomsPanel.tsx). Reticulum DM Share as paper / Scan paper via ChatDmPaperControls.tsx + createReticulumPaperMessage.ts. Scroll-at-bottom helper: chatScrollUtils.ts (getDistFromChatBottom).
  • Composer limits / send cadence: chatComposerLimits.ts — getMaxChunks(protocol) via ProtocolCapabilities.composerMaxChunks (MeshCore = 1: no outbound [i/N] split; splitChatMessage returns null when text needs more than one packet), room payload via getMeshcoreRoomPayloadLimit, computeComposerLimitStatus phases (warn surfaces a single-packet ⓘ hint; overMaxSingle disables send and shows a role="note" callout). MeshCore also gets a non-blocking ~5s "sending too fast" advisory (role="status", dismissible) from an app-wide clock in meshcoreSendRateNotice.ts (recordMeshcoreSend / isMeshcoreSendTooFast, MESHCORE_FAST_SEND_WARN_INTERVAL_MS). Every live MeshCore send advances the clock and can show the advisory — text (handleSend), GIF, and share-location in ChatComposer, plus outbox drain (useChatOutbox.ts). Legacy MeshCore outbox rows with groupTotal > 1 or [i/N] payloads are quarantined (blocked) on drain instead of being transmitted. Inbound multi-part [i/N] merge is unchanged. i18n: chatPanel.composeLimit.meshcoreSingleNotice.*, chatPanel.meshcoreFastSend.warning, chatPanel.outboxLegacyMultipartBlocked. See meshcore-meshtastic-parity.md.
  • Payload / links: ChatPayloadText.tsx — mention highlighting, search marks, URL linkification; link previews via chat:fetchLinkPreview (src/main/fetchLinkPreview.ts): Open Graph for HTML pages; YouTube watch/shorts/youtu.be via oEmbed + thumbnail; direct image URLs (path extension via chatDirectImageUrl.ts or raster Content-Type) return kind: 'image' and render as inline embeds (ChatInlineImage / DirectImageEmbed); OG/YouTube use card layout. Security: DNS-pinned undici Agent, private/loopback blocked, magic-byte MIME sniff (safeRasterImageMime.ts), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, 2 MiB image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: chat:readReticulumAttachmentAsDataUrl (reticulum-attachment-image.ts; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → ReticulumAttachmentLine. LXMF voice memos (hasReticulumVoiceMemo): Chat DM mic → sidecar /api/v1/voice/memo/* via electronAPI.reticulum.voiceMemo.*; playback via chat:readReticulumAttachmentBytes (reticulum-attachment-audio.ts; OggS sniff, 256 KiB) → ReticulumVoiceMemoLine. Reply quotes: replyPreview.ts.
  • Storage helpers: src/renderer/lib/chatPanelProtocolStorage.ts — drafts (mesh-client:drafts:<protocol>), open DM tabs, last-read, per-view mute (mesh-client:mutedViews:<protocol>), starred (mesh-client:starred:<protocol>, cap 200), MeshCore flood-scope overrides per chat view (mesh-client:floodScopeOverrides:<protocol>, channel or DM viewKey), weather-marked senders (mesh-client:weatherMarkedSenders:<protocol>, cap 500).
  • Weather view (#1112): src/renderer/lib/weatherPosts.ts (isWeatherPost: built-in patterns for °F/°C, 60F now, forecast, Hi N/Lo N, JSON with "temp"/"hum"; user-marked senders; optional user regex capped at 200 chars, invalid → ignored with inline error; WeakMap cache keyed by message + config) and src/renderer/stores/weatherFilterStore.ts (weatherFilterHideInChannels / weatherFilterPattern in app settings, marked senders per protocol). ChatPanel viewMode === 'weather' shows weather posts of the selected channel (channel tabs stay active; composer still targets that channel), with a toolbar count and inline settings (chat/WeatherFilterSettings.tsx); the sender header has a Mark as weather sender toggle. When hide-in-channels is on, isHiddenWeatherPost keeps those posts out of the channel view, computeChannelUnreadCounts, and resolveChatNotificationType (returns null), so badges and tones match what Chat shows. DMs are never filtered. Not shown for DM-only chat (Reticulum).
  • Notifications: src/renderer/lib/chatNotifications.ts — playMessageNotification(type) via Web Audio: channel = single 880 Hz pulse (150 ms); dm / reply = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap); mecp = repeated ascending triple pulse for ROUTINE; mecpSafety = short–long (dit–dah) pairs × 6 at 1175 Hz square (~4.4s) for SAFETY; mecpSiren = six-cycle siren (~5s, aligned with URGENT) for MAYDAY; mecpEas = 853+960 Hz EAS attention (~5s) for URGENT (mute-bypass via useMecpAlertWatcher / mecpAlert.ts, not pickAudibleNotificationType). Resumes suspended AudioContext when the window is hidden/minimized. Type selection in chatUnreadCounts.ts (resolveChatNotificationType, pickAudibleNotificationType; batch priority reply > dm > channel). ChatPanel plays when the user is on Chat but reading another view; App plays for other panels / backgrounded window (avoids double beep). The App inactive-chat watchers also raise desktop notifications, but only while the window is inactive. They cover all four chat surfaces: Meshtastic, MeshCore, Reticulum LXMF Chat and RRC. The logic lives in chatDesktopNotifications.ts and desktopNotification.ts. These notifications are visual-only (silent: true) and use the same mute gates as the sound. Each conversation is tagged <protocol>:<view>. Clicking one calls app:focusWindow and opens the DM, channel or RRC room. Typed Web Audio from App owns the sound. There is no runtime-local notifier. App → Notifications → Notification tones configures presets, imported short audio, volume and preview per event (notificationSoundSettings.ts; SQLite key notificationSounds). Main owns bounded per-event copies via notificationSounds:* IPC; existing sounds are defaults and missing custom audio falls back to the original. Preview cancellation is separate from incoming playback. See ../notification-sounds.md for platform survey and boundaries. Global mute mesh-client:notifMuted; per-view mute in mutedViews. Main-process tray icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (src/main/index.ts buildTrayIcon). MECP compose/export and red-dashed bubbles: see mecp.md.
  • Meshtastic dedup: meshtasticMessageDedup.ts — merges delayed RF/MQTT duplicates (10-minute content window) in useMeshtasticRuntime ingest.
  • Hop badges: MessageRecord.rxHops / viaStoreForward round-trip via storeRecordAdapters.ts and meshtasticDbCacheHydration.ts (hopCount on MessageRecord bridges to rxHops for Meshtastic PacketRouter rows); Chat hop pills read ChatMessage.rxHops. MeshCore (primary): companion pathLen on events 7/8 → meshcoreCompanionRxPathLenToHopCount → DomainEvent.payload.hopCount (MeshCoreProtocol, meshcoreDirectMessageDecode); waiting-message drain also sets rxHops from pathLen. MeshCore (fallback): raw-log correlation via resolveMeshcoreIngestRxHops / MESHCORE_CHAT_CORRELATE_WINDOW_MS (3000ms); rawPacketsRef synced inside event-136 setRawPackets updater for same-tick ingest (meshcoreConnSideEffects). Meshtastic: MeshtasticProtocol uses meshtasticComputedRfHopsAway — omit hops for viaMqtt and hopStart === 0; else hopStart - hopLimit when hopStart > 0 && hopLimit <= hopStart.
  • Bubbles and composer (v6, Option B): incoming bubbles use bg-chat-incoming-bg / border-chat-incoming-border, own bubbles bg-chat-outgoing-bg / border-chat-outgoing-border (all four user-editable in App → Appearance → Colors; THEME_TOKEN_ALPHA in themeColors.ts renders the fills translucent). DMs use the same tones as channels (no purple). rounded-xl with the small corner toward the sender (rounded-tl-sm in, rounded-tr-sm out), row max 80% (94% below sm, no rem cap, so wide windows keep long messages on one line); MECP severity tones (mecpChatBubbleToneClasses) always win. Incoming rows have a 24px avatar gutter (data-chat-avatar): sender initials from lib/senderInitials.ts, or the Reticulum LXMF face (own Reticulum messages keep the face in the header); compact-mode continuations get an empty spacer so a sender's run reads as one block. Incoming hops / S&F / transport sit at the end of the header line; only header-less compact continuations show them as a footer. Toolbar icons sit in one role="group" ("Conversation tools"). The composer looks the same for channels, DMs and Rooms; Send shows an icon plus its label (Queue / Send N parts stay words), and a keyboard hint line (chatPanel.composeHint, hidden on touch) gives way to the length counter.
  • Relay coverage (in-memory, outgoing bubbles only): relayCoverageStore keyed by identityId:messageId (not persisted). UI: RelayCoverageLine in ChatPanel status row via relayCoverageMessageKey. MeshCore: openHeardRepeatWindow from Chat useSendMessage channel sends and runtime channel TX; during MESHCORE_HEARD_REPEAT_WINDOW_MS (120s — large meshes often need ≥60s for multi-hop returns), every overheard GRP_TXT flood-path segment credits a forwarder (Repeater/Room when resolved via pubKeyMapRef / pubkey prefix; otherwise synthetic hex as “additional unidentified”). Chat hops are never credited. Path-invariant payload id (meshCorePathInvariantPayloadId: CRC lookup + full type/payload equality) binds only from own-TX or empty-path channel echo (never from the first credited hop); once bound, mismatched floods are ignored — unbound windows credit all in-window forwarders (pre-#888 best-effort). Naming is reliable on 2/3-byte path modes; 1-byte is best-effort (collisions common). Only forwarders whose rebroadcast reaches our radio are credited (multi-hop peers appear via accumulated path hashes). renameMessageId re-keys coverage + window; disconnect clears window + identity coverage. Meshtastic: binary heard/timeout for RF channel/broadcast via meshtasticHeardRepeat + useMeshtasticRuntime transport status (DMs/MQTT ignored). Reticulum: predicted route at send (reticulumRouteCoverage / sendReticulumChatMessage); hops-only vs via-first-hop i18n; pending→hash rename re-keys; clearReticulumSessionStores strips reticulum coverage. i18n: chatPanel.heardBy*, chatPanel.route*Predicted*.
  • MeshCore dedup: meshcoreStoreDedup.ts — RF/MQTT merge, companion TX echo, tapback self-echo, room BBS paths in useMeshcoreRuntime ingest (5-minute cross-transport window; distinct from Meshtastic’s 10-minute window).
  • Reactions / tapbacks: reactions.ts (Meshtastic protobuf) + meshcoreChannelText.ts (MeshCore default outbound keyless @[Name] emoji / @[Name] body; opt-in MeshCore Open compatibility in App enables keyed replies, r: reactions, and g: GIF send via meshcoreOpenWireCompatEnabled in appSettingsStorage.ts; inbound keyed/sec↔ms parent match via meshcoreMessageMatchesReplyKey; inbound emoji-only replies promoted via meshcorePromoteEmojiOnlyReplyToTapback; inbound Open r:HASH:INDEX in meshcoreOpenReaction.ts); ChatPanel.tsx attaches tapbacks via replyId + runtime/panel sendReaction.
  • Mention segments: src/renderer/lib/chatMentionSegments.ts — parse/build @[Name] tokens; MentionAutocomplete.tsx renders the dropdown.
  • Export IPC: chat:export — renderer calls window.electronAPI.chat.export(messages); main opens a Save dialog and writes a .txt file.
  • Support bundle IPC: support:exportBundle — exportSupportBundle.ts → window.electronAPI.support.exportBundle(mode, json); main support-bundle.ts writes zip (github = logs + debug snapshot including Reticulum diagnostic JSON and Meshtastic channel layout triage via debugSnapshotMeshtasticContext.ts; developer = SQLite plus redacted reticulum/config and reticulum/mesh_client_stack.json). Modes in support-bundle.types.ts.