Wire the send/receive paths and the chat UI on top of the file plane.
(Committed together because the UI renders the state the core wiring
produces.)
Core:
- CoreCommand::SendChatFile {text, attachment, data}: serve the bytes on
the file plane (serve_attachment) then broadcast the descriptor via
send_chat. CoreCommand::FetchAttachment {from, attachment}: detached
fetch -> AttachmentReady/AttachmentFailed.
- On an inbound Chat with an Image attachment, auto-fetch + defensively
re-validate (decodable + within pixel limits) before delivering;
non-images wait for an explicit fetch (the Save/Download chip).
- UiEvent::ChatMessage carries the attachment; new AttachmentReady /
AttachmentFailed events keyed by attachment id.
App:
- 📎 attach button + native picker; reads the file, enforces the size
cap, classifies image vs file, mints a random id, optimistically
echoes the message + caches our own bytes (so we see our own image
inline), and sends SendChatFile.
- Renders inline image thumbnails (handle cached by id to avoid the
per-redraw re-upload flicker), file chips with Save/Download, a
loading placeholder for in-flight images, and an error line on
failure. Image messages with no caption still render.
- SaveAttachment: saves immediately if bytes are in hand, else fetches
then saves when ready (pending_saves) via a native save dialog;
filename defaulted from the sanitized descriptor.
- Session-only: attachment bytes/handles cleared on leave, never
persisted.
Binary + clippy clean, 349 lib tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
157 lines
8.8 KiB
Rust
157 lines
8.8 KiB
Rust
use crate::config::{NetworkMode, RecordingMode};
|
|
use crate::friends::Friend;
|
|
use crate::network::PeerState;
|
|
use crate::presence::{FriendPresence, PresenceMode};
|
|
use iroh::{EndpointAddr, EndpointId};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum CoreCommand {
|
|
/// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a
|
|
/// share ticket to join. `room_name` is the creator's chosen cosmetic label
|
|
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
|
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
|
Leave,
|
|
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
|
/// audio/screen-share work, close the persistent network stack, then ack with
|
|
/// [`UiEvent::ShutdownComplete`].
|
|
Shutdown,
|
|
ToggleMute,
|
|
/// Change our avatar (W4) and re-announce it to the room over presence.
|
|
SetAvatar(crate::avatar::Avatar),
|
|
ToggleDeafen,
|
|
SetPttMode(bool),
|
|
SetPttActive(bool),
|
|
SetPeerVolume(EndpointId, f32),
|
|
/// Listener-side per-peer EQ. Local only; never leaves this app instance.
|
|
SetPeerEq(EndpointId, crate::audio::eq::EqSettings),
|
|
/// Listener-side per-peer pan. Local only; never leaves this app instance.
|
|
SetPeerPan(EndpointId, f32),
|
|
/// Listener-side per-peer noise gate threshold (normalized RMS, `0.0` = off).
|
|
/// Applies the same smooth gate as the mic path to a peer's incoming audio,
|
|
/// to suppress their background noise on our end. Local only.
|
|
SetPeerGate(EndpointId, f32),
|
|
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
|
|
/// still show) but not mixed into our output.
|
|
SetPeerMuted(EndpointId, bool),
|
|
SetNoiseGateThreshold(f32),
|
|
/// App-internal capture gain (mic), applied before the gate/encode. 1.0 = unity.
|
|
SetInputVolume(f32),
|
|
/// App-internal playback gain on the mixed output. 1.0 = unity.
|
|
SetOutputVolume(f32),
|
|
/// Start/stop a standalone capture-only stream that reports the raw mic
|
|
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
|
|
/// Ignored while a room session is active (the in-call meter covers that).
|
|
SetMicMonitor { enabled: bool, input_device: Option<String> },
|
|
/// Set the relay/discovery posture. Takes effect on the next room join,
|
|
/// since the endpoint is (re)built then.
|
|
SetNetworkMode(NetworkMode),
|
|
/// Start/stop recording the call to a local WAV (your mic + the incoming
|
|
/// mix). No-op start if already recording / not in a call.
|
|
SetRecording(bool),
|
|
/// Set what a recording captures (mixed / per-peer stems / both). Takes
|
|
/// effect on the next recording start. Sent at startup from config.
|
|
SetRecordingMode(RecordingMode),
|
|
/// Broadcast a room text-chat message. No-op when not in a call.
|
|
SendChat(String),
|
|
/// Send a chat message carrying a file attachment. The app has already read +
|
|
/// capped the file and built the descriptor; core makes the bytes available
|
|
/// on the file plane and broadcasts the descriptor.
|
|
SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec<u8> },
|
|
/// Fetch a received attachment's bytes from its sender over the file plane
|
|
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
|
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
|
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment },
|
|
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
|
/// Sent at startup so screen-share can resolve the binary.
|
|
SetPixelpassPath(Option<String>),
|
|
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
|
|
/// on our presence so the room can watch. No-op when not in a call.
|
|
StartScreenShare,
|
|
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
|
/// ticket. No-op when not sharing.
|
|
StopScreenShare,
|
|
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
|
/// open it in a local player.
|
|
ViewShare(String),
|
|
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
|
|
/// on the next room join (the endpoint is rebuilt then). The core replies with
|
|
/// an updated [`UiEvent::IdentityStatus`].
|
|
RegenerateIdentity,
|
|
/// Add a friend (W7). Core owns the friends store: it mutates + persists it and
|
|
/// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known
|
|
/// (e.g. added from a room). Idempotent — re-adding an existing id is a no-op.
|
|
AddFriend { id: EndpointId, name: String, addr: Option<EndpointAddr> },
|
|
/// Remove a friend by id (W7).
|
|
RemoveFriend(EndpointId),
|
|
/// Locally rename a friend (W7).
|
|
RenameFriend(EndpointId, String),
|
|
/// Set our presence posture (W7). Gates the idle listener (answer friends-only /
|
|
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
|
/// startup from config and whenever the user changes it.
|
|
SetPresenceMode(PresenceMode),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum UiEvent {
|
|
RoomJoined { ticket: String, self_id: String },
|
|
RoomLeft,
|
|
PeerJoined { id: EndpointId, state: PeerState },
|
|
PeerLeft { id: EndpointId },
|
|
/// The fixed reconnect grace expired and bounded background gossip recovery
|
|
/// has started. This is non-terminal and must not play the failure chime.
|
|
PeerRecoveryStarted { id: EndpointId },
|
|
PeerConnectionFailed { id: EndpointId },
|
|
PeerUpdated { id: EndpointId, state: PeerState },
|
|
/// Audio link to a peer is being (re)established — show a connecting state.
|
|
PeerConnecting { id: EndpointId },
|
|
/// Audio link to a peer is up and carrying audio.
|
|
PeerConnected { id: EndpointId },
|
|
AudioLevels(Vec<(EndpointId, f32)>),
|
|
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
|
|
/// for the settings level meter. Throttled to ~10/sec.
|
|
MicLevel(f32),
|
|
/// Call recording started; carries the absolute WAV path being written.
|
|
RecordingStarted { path: String },
|
|
/// Call recording stopped; carries the finished WAV path.
|
|
RecordingStopped { path: String },
|
|
/// A room text-chat message arrived from a peer (never our own — local
|
|
/// messages are echoed by the UI on send). `from` is the sender's node id
|
|
/// string, used to key their avatar (W4).
|
|
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
|
|
/// An attachment's bytes are now available (auto-fetched for images, or
|
|
/// fetched on demand for files). Keyed by attachment id so the UI can match
|
|
/// it to the chat entry.
|
|
AttachmentReady { id: crate::files::AttachmentId, data: Vec<u8> },
|
|
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
|
AttachmentFailed { id: crate::files::AttachmentId, error: String },
|
|
/// Our own screen share started; the UI flips the Share button to "Stop".
|
|
ScreenShareStarted,
|
|
/// Our own screen share stopped (or failed to start).
|
|
ScreenShareStopped,
|
|
/// Our node identity (W7): the current node id string, and whether it is
|
|
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
|
/// `persisted = false` means the key file couldn't be read/written and we're
|
|
/// running on an ephemeral fallback — a degraded state the UI must surface,
|
|
/// since the id (and thus friend recognition) won't survive the next launch.
|
|
/// `error` carries the reason when degraded, for the UI explainer.
|
|
IdentityStatus { node_id: String, persisted: bool, error: Option<String> },
|
|
/// The friends list (W7), now owned by core. Sent at startup (after load) and
|
|
/// after every add/remove/rename so the GUI renders from this snapshot instead
|
|
/// of owning the store. `read_only` is true when `friends.json` failed to load
|
|
/// (malformed) — the GUI shows a degraded warning and disables edits so we never
|
|
/// overwrite the damaged file (backlog A16).
|
|
FriendsUpdated { friends: Vec<Friend>, read_only: bool },
|
|
/// A friend's live presence from a successful ping reply (W7): online, or in a
|
|
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
|
/// scheduler; absence of a recent event = treat as offline.
|
|
FriendPresence { id: EndpointId, presence: FriendPresence },
|
|
/// Core corrected the committed presence posture. Usually the Discoverable
|
|
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
|
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
|
/// persist this so its presence picker matches the endpoint's discovery state.
|
|
PresenceModeReverted { mode: PresenceMode },
|
|
/// Core finished orderly app shutdown and the GUI can exit.
|
|
ShutdownComplete,
|
|
Error(String),
|
|
}
|