use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CallType {
#[default]
Direct,
Group,
Channel,
}
impl CallType {
pub fn label(&self) -> &'static str {
match self {
Self::Direct => "Call",
Self::Group => "Group Call",
Self::Channel => "Voice Channel",
}
}
pub fn default_max_participants(&self) -> u32 {
match self {
Self::Direct => 2,
Self::Group => 25,
Self::Channel => 100,
}
}
pub fn has_host_controls(&self) -> bool {
!matches!(self, Self::Direct)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ParticipantRole {
#[default]
Participant,
CoHost,
Host,
}
impl ParticipantRole {
pub fn label(&self) -> &'static str {
match self {
Self::Participant => "Participant",
Self::CoHost => "Co-Host",
Self::Host => "Host",
}
}
pub fn can_mute_others(&self) -> bool {
matches!(self, Self::CoHost | Self::Host)
}
pub fn can_remove_participants(&self) -> bool {
matches!(self, Self::CoHost | Self::Host)
}
pub fn can_promote(&self) -> bool {
matches!(self, Self::Host)
}
pub fn can_end_call(&self) -> bool {
matches!(self, Self::Host)
}
pub fn can_manage_recording(&self) -> bool {
matches!(self, Self::CoHost | Self::Host)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CallState {
#[default]
Idle,
Connecting,
InCall,
Reconnecting,
Disconnected,
MediaError,
}
impl CallState {
pub fn is_active(&self) -> bool {
matches!(self, Self::Connecting | Self::InCall | Self::Reconnecting)
}
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Idle | Self::Disconnected | Self::MediaError)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum RecordingState {
#[default]
NotRecording,
Recording,
Paused,
Finalizing,
}
impl RecordingState {
pub fn is_active(&self) -> bool {
matches!(self, Self::Recording | Self::Paused | Self::Finalizing)
}
pub fn label(&self) -> &'static str {
match self {
Self::NotRecording => "Not Recording",
Self::Recording => "Recording",
Self::Paused => "Paused",
Self::Finalizing => "Saving...",
}
}
pub fn status_class(&self) -> &'static str {
match self {
Self::NotRecording => "text-slate-400",
Self::Recording => "text-red-500 animate-pulse",
Self::Paused => "text-amber-500",
Self::Finalizing => "text-blue-500",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScreenShareSourceType {
Monitor,
Window,
}
impl ScreenShareSourceType {
pub fn label(&self) -> &'static str {
match self {
Self::Monitor => "Entire Screen",
Self::Window => "Application Window",
}
}
pub fn icon(&self) -> &'static str {
match self {
Self::Monitor => "display",
Self::Window => "window",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScreenShareSource {
pub id: String,
pub name: String,
pub source_type: ScreenShareSourceType,
pub app_name: Option<String>,
pub is_primary: bool,
pub thumbnail: Option<String>,
pub thumbnail_width: Option<u32>,
pub thumbnail_height: Option<u32>,
}
impl ScreenShareSource {
pub fn monitor(id: impl Into<String>, name: impl Into<String>, is_primary: bool) -> Self {
Self {
id: id.into(),
name: name.into(),
source_type: ScreenShareSourceType::Monitor,
app_name: None,
is_primary,
thumbnail: None,
thumbnail_width: None,
thumbnail_height: None,
}
}
pub fn window(
id: impl Into<String>,
name: impl Into<String>,
app_name: impl Into<String>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
source_type: ScreenShareSourceType::Window,
app_name: Some(app_name.into()),
is_primary: false,
thumbnail: None,
thumbnail_width: None,
thumbnail_height: None,
}
}
pub fn with_thumbnail(mut self, data: String, width: u32, height: u32) -> Self {
self.thumbnail = Some(data);
self.thumbnail_width = Some(width);
self.thumbnail_height = Some(height);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScreenShareInfo {
pub source: ScreenShareSource,
pub started_at: u64,
pub allow_control: bool,
pub share_audio: bool,
}
impl ScreenShareInfo {
pub fn new(source: ScreenShareSource, started_at: u64) -> Self {
Self {
source,
started_at,
allow_control: false,
share_audio: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecordingInfo {
pub id: String,
pub started_at: u64,
pub duration_ms: u64,
pub state: RecordingState,
pub file_path: Option<String>,
pub file_size_bytes: u64,
pub includes_audio: bool,
pub includes_video: bool,
pub includes_screen: bool,
pub started_by: String,
}
impl Default for RecordingInfo {
fn default() -> Self {
Self {
id: String::new(),
started_at: 0,
duration_ms: 0,
state: RecordingState::NotRecording,
file_path: None,
file_size_bytes: 0,
includes_audio: true,
includes_video: false,
includes_screen: false,
started_by: String::new(),
}
}
}
impl RecordingInfo {
pub fn formatted_duration(&self) -> String {
let total_seconds = self.duration_ms / 1000;
let minutes = total_seconds / 60;
let seconds = total_seconds % 60;
format!("{:02}:{:02}", minutes, seconds)
}
pub fn formatted_size(&self) -> String {
if self.file_size_bytes < 1024 {
format!("{} B", self.file_size_bytes)
} else if self.file_size_bytes < 1024 * 1024 {
format!("{:.1} KB", self.file_size_bytes as f64 / 1024.0)
} else if self.file_size_bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", self.file_size_bytes as f64 / (1024.0 * 1024.0))
} else {
format!(
"{:.2} GB",
self.file_size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DeviceType {
Microphone,
Speaker,
Camera,
}
impl DeviceType {
pub fn label(&self) -> &'static str {
match self {
Self::Microphone => "Microphone",
Self::Speaker => "Speaker",
Self::Camera => "Camera",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MediaDevice {
pub id: String,
pub name: String,
pub device_type: DeviceType,
pub is_default: bool,
pub is_available: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Participant {
pub id: String,
pub display_name: String,
pub four_words: String,
pub role: ParticipantRole,
pub is_muted: bool,
pub is_muted_by_host: bool,
pub is_video_enabled: bool,
pub is_speaking: bool,
pub is_screen_sharing: bool,
pub hand_raised: bool,
pub audio_level: f32,
pub joined_at: i64,
}
impl Participant {
pub fn has_active_media(&self) -> bool {
!self.is_muted || self.is_video_enabled || self.is_screen_sharing
}
pub fn is_host(&self) -> bool {
matches!(self.role, ParticipantRole::Host)
}
pub fn has_elevated_role(&self) -> bool {
matches!(self.role, ParticipantRole::Host | ParticipantRole::CoHost)
}
pub fn can_self_unmute(&self) -> bool {
!self.is_muted_by_host || self.has_elevated_role()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CallInfo {
pub call_id: String,
pub entity_id: String,
pub entity_name: String,
pub call_type: CallType,
pub participants: Vec<Participant>,
pub started_at: i64,
pub duration_seconds: u64,
pub my_participant_id: String,
pub host_id: String,
pub max_participants: u32,
pub is_locked: bool,
pub mute_on_entry: bool,
}
impl CallInfo {
pub fn participant_count(&self) -> usize {
self.participants.len()
}
pub fn my_participant(&self) -> Option<&Participant> {
self.participants
.iter()
.find(|p| p.id == self.my_participant_id)
}
pub fn other_participants(&self) -> Vec<&Participant> {
self.participants
.iter()
.filter(|p| p.id != self.my_participant_id)
.collect()
}
pub fn host(&self) -> Option<&Participant> {
self.participants.iter().find(|p| p.id == self.host_id)
}
pub fn am_i_host(&self) -> bool {
self.my_participant_id == self.host_id
}
pub fn am_i_elevated(&self) -> bool {
self.my_participant()
.map(|p| p.has_elevated_role())
.unwrap_or(false)
}
pub fn my_role(&self) -> ParticipantRole {
self.my_participant()
.map(|p| p.role)
.unwrap_or(ParticipantRole::Participant)
}
pub fn is_group_call(&self) -> bool {
!matches!(self.call_type, CallType::Direct)
}
pub fn can_accept_more_participants(&self) -> bool {
!self.is_locked && (self.participants.len() as u32) < self.max_participants
}
pub fn elevated_participants(&self) -> Vec<&Participant> {
self.participants
.iter()
.filter(|p| p.has_elevated_role())
.collect()
}
pub fn participants_with_raised_hands(&self) -> Vec<&Participant> {
self.participants.iter().filter(|p| p.hand_raised).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MediaErrorKind {
PermissionDenied,
DeviceNotFound,
DeviceInUse,
NotSupported,
Unknown,
}
impl MediaErrorKind {
pub fn description(&self) -> &'static str {
match self {
Self::PermissionDenied => "Permission denied",
Self::DeviceNotFound => "Device not found",
Self::DeviceInUse => "Device is in use",
Self::NotSupported => "Not supported",
Self::Unknown => "Unknown error",
}
}
pub fn suggestion(&self) -> &'static str {
match self {
Self::PermissionDenied => "Check your system permissions for this application",
Self::DeviceNotFound => "Connect a microphone or camera and try again",
Self::DeviceInUse => "Close other applications using this device",
Self::NotSupported => "Try using a different device",
Self::Unknown => "Try again or restart the application",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MediaError {
pub device_type: DeviceType,
pub error_kind: MediaErrorKind,
pub message: String,
pub recoverable: bool,
}
impl MediaError {
pub fn new(
device_type: DeviceType,
error_kind: MediaErrorKind,
message: impl Into<String>,
) -> Self {
let recoverable = !matches!(error_kind, MediaErrorKind::NotSupported);
Self {
device_type,
error_kind,
message: message.into(),
recoverable,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ConnectionQuality {
#[default]
Unknown,
Excellent,
Good,
Fair,
Poor,
Critical,
}
impl ConnectionQuality {
pub fn label(&self) -> &'static str {
match self {
Self::Unknown => "Unknown",
Self::Excellent => "Excellent",
Self::Good => "Good",
Self::Fair => "Fair",
Self::Poor => "Poor",
Self::Critical => "Critical",
}
}
pub fn color_class(&self) -> &'static str {
match self {
Self::Unknown => "text-slate-400",
Self::Excellent => "text-emerald-400",
Self::Good => "text-emerald-500",
Self::Fair => "text-amber-400",
Self::Poor => "text-orange-500",
Self::Critical => "text-red-500",
}
}
pub fn signal_bars(&self) -> u8 {
match self {
Self::Unknown => 0,
Self::Critical => 1,
Self::Poor => 2,
Self::Fair => 3,
Self::Good => 4,
Self::Excellent => 5,
}
}
pub fn from_metrics(latency_ms: u32, packet_loss_percent: f32) -> Self {
if packet_loss_percent > 10.0 || latency_ms > 500 {
Self::Critical
} else if packet_loss_percent > 5.0 || latency_ms > 300 {
Self::Poor
} else if packet_loss_percent > 2.0 || latency_ms > 150 {
Self::Fair
} else if packet_loss_percent > 1.0 || latency_ms > 50 {
Self::Good
} else {
Self::Excellent
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QualityMetrics {
pub latency_ms: u32,
pub packet_loss_percent: f32,
pub jitter_ms: u32,
pub audio_bitrate_kbps: u32,
pub video_bitrate_kbps: u32,
pub video_width: u32,
pub video_height: u32,
pub video_fps: u32,
pub bytes_sent: u64,
pub bytes_received: u64,
pub quality: ConnectionQuality,
pub timestamp: u64,
}
impl Default for QualityMetrics {
fn default() -> Self {
Self {
latency_ms: 0,
packet_loss_percent: 0.0,
jitter_ms: 0,
audio_bitrate_kbps: 0,
video_bitrate_kbps: 0,
video_width: 0,
video_height: 0,
video_fps: 0,
bytes_sent: 0,
bytes_received: 0,
quality: ConnectionQuality::Unknown,
timestamp: 0,
}
}
}
impl QualityMetrics {
pub fn has_video(&self) -> bool {
self.video_width > 0 && self.video_height > 0
}
pub fn video_resolution(&self) -> String {
if self.has_video() {
format!("{}x{}", self.video_width, self.video_height)
} else {
"None".to_string()
}
}
pub fn total_bitrate_kbps(&self) -> u32 {
self.audio_bitrate_kbps + self.video_bitrate_kbps
}
pub fn recalculate_quality(&mut self) {
self.quality = ConnectionQuality::from_metrics(self.latency_ms, self.packet_loss_percent);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ParticipantQuality {
pub participant_id: String,
pub incoming: QualityMetrics,
pub outgoing: Option<QualityMetrics>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct CallSettings {
pub selected_microphone: Option<String>,
pub selected_speaker: Option<String>,
pub selected_camera: Option<String>,
pub auto_mute_on_join: bool,
pub noise_suppression: bool,
pub recording_enabled: bool,
pub recording_include_video: bool,
pub recording_directory: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct CallSnapshot {
pub state: CallState,
pub call_info: Option<CallInfo>,
pub participants: Vec<Participant>,
pub media_errors: Vec<MediaError>,
pub available_devices: Vec<MediaDevice>,
pub settings: CallSettings,
pub listen_only_mode: bool,
pub is_screen_sharing: bool,
pub screen_share_info: Option<ScreenShareInfo>,
pub available_screen_sources: Vec<ScreenShareSource>,
pub quality_metrics: QualityMetrics,
pub participant_quality: Vec<ParticipantQuality>,
pub is_recording: bool,
pub recording_info: Option<RecordingInfo>,
}
impl CallSnapshot {
pub fn is_in_call(&self) -> bool {
self.state == CallState::InCall
}
pub fn has_critical_errors(&self) -> bool {
self.media_errors.iter().any(|e| !e.recoverable)
}
pub fn get_participant_quality(&self, participant_id: &str) -> Option<&ParticipantQuality> {
self.participant_quality
.iter()
.find(|q| q.participant_id == participant_id)
}
pub fn connection_quality(&self) -> ConnectionQuality {
self.quality_metrics.quality
}
pub fn has_quality_issues(&self) -> bool {
matches!(
self.quality_metrics.quality,
ConnectionQuality::Poor | ConnectionQuality::Critical
)
}
pub fn recording_state(&self) -> RecordingState {
self.recording_info
.as_ref()
.map(|r| r.state)
.unwrap_or(RecordingState::NotRecording)
}
pub fn is_recording_active(&self) -> bool {
self.recording_state().is_active()
}
pub fn recording_duration(&self) -> Option<String> {
self.recording_info.as_ref().map(|r| r.formatted_duration())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CallOutcome {
Completed,
NoAnswer,
Declined,
Missed,
Failed,
Cancelled,
#[default]
InProgress,
}
impl CallOutcome {
pub fn label(&self) -> &'static str {
match self {
Self::Completed => "Completed",
Self::NoAnswer => "No Answer",
Self::Declined => "Declined",
Self::Missed => "Missed",
Self::Failed => "Failed",
Self::Cancelled => "Cancelled",
Self::InProgress => "In Progress",
}
}
pub fn is_missed(&self) -> bool {
matches!(self, Self::Missed)
}
pub fn was_connected(&self) -> bool {
matches!(self, Self::Completed | Self::InProgress)
}
pub fn status_class(&self) -> &'static str {
match self {
Self::Completed => "text-emerald-500",
Self::NoAnswer => "text-amber-500",
Self::Declined => "text-slate-500",
Self::Missed => "text-red-500",
Self::Failed => "text-red-600",
Self::Cancelled => "text-slate-400",
Self::InProgress => "text-blue-500",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CallDirection {
#[default]
Outgoing,
Incoming,
}
impl CallDirection {
pub fn label(&self) -> &'static str {
match self {
Self::Outgoing => "Outgoing",
Self::Incoming => "Incoming",
}
}
pub fn icon(&self) -> &'static str {
match self {
Self::Outgoing => "↗️",
Self::Incoming => "↙️",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HistoryParticipant {
pub id: String,
pub display_name: String,
pub four_words: String,
pub duration_seconds: u64,
pub partial_participation: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CallHistoryEntry {
pub call_id: String,
pub entity_id: String,
pub entity_name: String,
pub call_type: CallType,
pub direction: CallDirection,
pub outcome: CallOutcome,
pub started_at: i64,
pub ended_at: i64,
pub duration_seconds: u64,
pub participants: Vec<HistoryParticipant>,
pub had_video: bool,
pub had_screen_share: bool,
pub was_recorded: bool,
pub recording_path: Option<String>,
pub is_read: bool,
#[serde(default)]
pub has_called_back: bool,
}
impl CallHistoryEntry {
pub fn new_outgoing(
call_id: String,
entity_id: String,
entity_name: String,
call_type: CallType,
) -> Self {
let started_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
Self {
call_id,
entity_id,
entity_name,
call_type,
direction: CallDirection::Outgoing,
outcome: CallOutcome::InProgress,
started_at,
ended_at: 0,
duration_seconds: 0,
participants: Vec::new(),
had_video: false,
had_screen_share: false,
was_recorded: false,
recording_path: None,
is_read: true, has_called_back: false,
}
}
pub fn new_incoming(
call_id: String,
entity_id: String,
entity_name: String,
call_type: CallType,
) -> Self {
let started_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
Self {
call_id,
entity_id,
entity_name,
call_type,
direction: CallDirection::Incoming,
outcome: CallOutcome::InProgress,
started_at,
ended_at: 0,
duration_seconds: 0,
participants: Vec::new(),
had_video: false,
had_screen_share: false,
was_recorded: false,
recording_path: None,
is_read: false, has_called_back: false,
}
}
pub fn finalize(&mut self, outcome: CallOutcome) {
let ended_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
self.ended_at = ended_at;
self.outcome = outcome;
if outcome.was_connected() && self.started_at > 0 && ended_at > self.started_at {
self.duration_seconds = ((ended_at - self.started_at) / 1000) as u64;
}
}
pub fn formatted_duration(&self) -> String {
let hours = self.duration_seconds / 3600;
let minutes = (self.duration_seconds % 3600) / 60;
let seconds = self.duration_seconds % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, minutes, seconds)
} else {
format!("{}:{:02}", minutes, seconds)
}
}
pub fn participant_count(&self) -> usize {
self.participants.len()
}
pub fn participants_display(&self) -> String {
match self.participants.len() {
0 => self.entity_name.clone(),
1 => self.participants[0].display_name.clone(),
2 => format!(
"{} and {}",
self.participants[0].display_name, self.participants[1].display_name
),
n => format!("{} and {} others", self.participants[0].display_name, n - 1),
}
}
pub fn is_unread_missed(&self) -> bool {
self.outcome.is_missed() && !self.is_read
}
pub fn mark_read(&mut self) {
self.is_read = true;
}
pub fn icon(&self) -> &'static str {
match (&self.direction, &self.outcome) {
(_, CallOutcome::Missed) => "📵",
(_, CallOutcome::Failed) => "❌",
(CallDirection::Outgoing, CallOutcome::NoAnswer) => "📤",
(CallDirection::Incoming, _) => "📲",
(CallDirection::Outgoing, _) => "📱",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CallHistory {
pub entries: Vec<CallHistoryEntry>,
pub max_entries: usize,
}
impl CallHistory {
pub fn new(max_entries: usize) -> Self {
Self {
entries: Vec::new(),
max_entries,
}
}
pub fn add(&mut self, entry: CallHistoryEntry) {
self.entries.insert(0, entry);
if self.max_entries > 0 && self.entries.len() > self.max_entries {
self.entries.truncate(self.max_entries);
}
}
pub fn update(&mut self, call_id: &str, updater: impl FnOnce(&mut CallHistoryEntry)) {
if let Some(entry) = self.entries.iter_mut().find(|e| e.call_id == call_id) {
updater(entry);
}
}
pub fn get(&self, call_id: &str) -> Option<&CallHistoryEntry> {
self.entries.iter().find(|e| e.call_id == call_id)
}
pub fn get_mut(&mut self, call_id: &str) -> Option<&mut CallHistoryEntry> {
self.entries.iter_mut().find(|e| e.call_id == call_id)
}
pub fn remove(&mut self, call_id: &str) -> Option<CallHistoryEntry> {
if let Some(pos) = self.entries.iter().position(|e| e.call_id == call_id) {
Some(self.entries.remove(pos))
} else {
None
}
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn missed_calls(&self) -> Vec<&CallHistoryEntry> {
self.entries
.iter()
.filter(|e| e.outcome.is_missed())
.collect()
}
pub fn unread_missed_calls(&self) -> Vec<&CallHistoryEntry> {
self.entries
.iter()
.filter(|e| e.is_unread_missed())
.collect()
}
pub fn unread_missed_count(&self) -> usize {
self.entries.iter().filter(|e| e.is_unread_missed()).count()
}
pub fn mark_all_read(&mut self) {
for entry in &mut self.entries {
entry.is_read = true;
}
}
pub fn for_entity(&self, entity_id: &str) -> Vec<&CallHistoryEntry> {
self.entries
.iter()
.filter(|e| e.entity_id == entity_id)
.collect()
}
pub fn by_type(&self, call_type: CallType) -> Vec<&CallHistoryEntry> {
self.entries
.iter()
.filter(|e| e.call_type == call_type)
.collect()
}
pub fn recent(&self, limit: usize) -> Vec<&CallHistoryEntry> {
self.entries.iter().take(limit).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MissedCallNotification {
pub id: String,
pub call_id: String,
pub caller_id: String,
pub caller_name: String,
pub call_type: CallType,
pub timestamp: i64,
pub is_acknowledged: bool,
pub has_called_back: bool,
}
impl MissedCallNotification {
pub fn from_history_entry(entry: &CallHistoryEntry) -> Self {
Self {
id: format!("missed-{}", entry.call_id),
call_id: entry.call_id.clone(),
caller_id: entry.entity_id.clone(),
caller_name: entry.entity_name.clone(),
call_type: entry.call_type,
timestamp: entry.started_at,
is_acknowledged: entry.is_read,
has_called_back: entry.has_called_back,
}
}
pub fn is_urgent(&self) -> bool {
!self.is_acknowledged && !self.has_called_back
}
pub fn time_ago(&self) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let diff_ms = now - self.timestamp;
let diff_secs = diff_ms / 1000;
let diff_mins = diff_secs / 60;
let diff_hours = diff_mins / 60;
let diff_days = diff_hours / 24;
if diff_days > 0 {
format!("{}d ago", diff_days)
} else if diff_hours > 0 {
format!("{}h ago", diff_hours)
} else if diff_mins > 0 {
format!("{}m ago", diff_mins)
} else {
"Just now".to_string()
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MissedCallsSnapshot {
pub notifications: Vec<MissedCallNotification>,
pub unread_count: usize,
pub last_updated: i64,
}
impl MissedCallsSnapshot {
pub fn has_unread(&self) -> bool {
self.unread_count > 0
}
pub fn for_caller(&self, caller_id: &str) -> Vec<&MissedCallNotification> {
self.notifications
.iter()
.filter(|n| n.caller_id == caller_id)
.collect()
}
pub fn unread(&self) -> Vec<&MissedCallNotification> {
self.notifications
.iter()
.filter(|n| !n.is_acknowledged)
.collect()
}
}
pub const MAX_PENDING_INVITES: usize = 10;
pub const PENDING_INVITE_EXPIRY_MS: i64 = 5 * 60 * 1000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingCallInvite {
pub id: String,
pub call_id: String,
pub caller_id: String,
pub caller_name: String,
pub entity_id: String,
pub call_type: CallType,
pub received_at: i64,
pub expires_at: i64,
}
impl PendingCallInvite {
pub fn new(
call_id: String,
caller_id: String,
caller_name: String,
entity_id: String,
call_type: CallType,
) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
Self {
id: format!("invite-{}-{}", call_id, now),
call_id,
caller_id,
caller_name,
entity_id,
call_type,
received_at: now,
expires_at: now + PENDING_INVITE_EXPIRY_MS,
}
}
pub fn is_expired(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
now >= self.expires_at
}
pub fn time_remaining(&self) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let remaining_ms = self.expires_at.saturating_sub(now);
if remaining_ms <= 0 {
return "Expired".to_string();
}
let remaining_secs = remaining_ms / 1000;
let remaining_mins = remaining_secs / 60;
if remaining_mins > 0 {
format!("{}m {}s", remaining_mins, remaining_secs % 60)
} else {
format!("{}s", remaining_secs)
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PendingInvitesSnapshot {
pub invites: Vec<PendingCallInvite>,
pub count: usize,
pub last_updated: i64,
}
impl PendingInvitesSnapshot {
pub fn has_invites(&self) -> bool {
!self.invites.is_empty()
}
pub fn for_caller(&self, caller_id: &str) -> Vec<&PendingCallInvite> {
self.invites
.iter()
.filter(|i| i.caller_id == caller_id)
.collect()
}
pub fn most_urgent(&self) -> Option<&PendingCallInvite> {
self.invites.iter().find(|i| !i.is_expired())
}
pub fn active(&self) -> Vec<&PendingCallInvite> {
self.invites.iter().filter(|i| !i.is_expired()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn call_state_is_active() {
assert!(!CallState::Idle.is_active());
assert!(CallState::Connecting.is_active());
assert!(CallState::InCall.is_active());
assert!(CallState::Reconnecting.is_active());
assert!(!CallState::Disconnected.is_active());
assert!(!CallState::MediaError.is_active());
}
#[test]
fn call_state_is_terminal() {
assert!(CallState::Idle.is_terminal());
assert!(!CallState::Connecting.is_terminal());
assert!(!CallState::InCall.is_terminal());
assert!(!CallState::Reconnecting.is_terminal());
assert!(CallState::Disconnected.is_terminal());
assert!(CallState::MediaError.is_terminal());
}
#[test]
fn device_type_label() {
assert_eq!(DeviceType::Microphone.label(), "Microphone");
assert_eq!(DeviceType::Speaker.label(), "Speaker");
assert_eq!(DeviceType::Camera.label(), "Camera");
}
#[test]
fn participant_has_active_media() {
let participant = Participant {
id: "p1".to_string(),
display_name: "Alice".to_string(),
four_words: "ocean-forest-moon-star".to_string(),
role: ParticipantRole::Participant,
is_muted: true,
is_muted_by_host: false,
is_video_enabled: false,
is_speaking: false,
is_screen_sharing: false,
hand_raised: false,
audio_level: 0.0,
joined_at: 0,
};
assert!(!participant.has_active_media());
let participant_with_audio = Participant {
is_muted: false,
..participant.clone()
};
assert!(participant_with_audio.has_active_media());
let participant_with_video = Participant {
is_video_enabled: true,
..participant.clone()
};
assert!(participant_with_video.has_active_media());
let participant_with_screen_share = Participant {
is_screen_sharing: true,
..participant
};
assert!(participant_with_screen_share.has_active_media());
}
#[test]
fn call_info_participant_helpers() {
let call = CallInfo {
call_id: "call1".to_string(),
entity_id: "ent1".to_string(),
entity_name: "Team Chat".to_string(),
call_type: CallType::Group,
participants: vec![
Participant {
id: "me".to_string(),
display_name: "Me".to_string(),
four_words: "a-b-c-d".to_string(),
role: ParticipantRole::Host,
is_muted: false,
is_muted_by_host: false,
is_video_enabled: false,
is_speaking: false,
is_screen_sharing: false,
hand_raised: false,
audio_level: 0.0,
joined_at: 0,
},
Participant {
id: "other".to_string(),
display_name: "Other".to_string(),
four_words: "e-f-g-h".to_string(),
role: ParticipantRole::Participant,
is_muted: false,
is_muted_by_host: false,
is_video_enabled: false,
is_speaking: false,
is_screen_sharing: false,
hand_raised: false,
audio_level: 0.0,
joined_at: 0,
},
],
started_at: 0,
duration_seconds: 0,
my_participant_id: "me".to_string(),
host_id: "me".to_string(),
max_participants: 25,
is_locked: false,
mute_on_entry: false,
};
assert_eq!(call.participant_count(), 2);
assert_eq!(
call.my_participant().map(|p| &p.display_name),
Some(&"Me".to_string())
);
assert_eq!(call.other_participants().len(), 1);
assert_eq!(call.other_participants()[0].display_name, "Other");
}
#[test]
fn media_error_kind_descriptions() {
assert_eq!(
MediaErrorKind::PermissionDenied.description(),
"Permission denied"
);
assert_eq!(
MediaErrorKind::DeviceNotFound.description(),
"Device not found"
);
assert_eq!(
MediaErrorKind::DeviceInUse.description(),
"Device is in use"
);
assert_eq!(MediaErrorKind::NotSupported.description(), "Not supported");
assert_eq!(MediaErrorKind::Unknown.description(), "Unknown error");
}
#[test]
fn media_error_recoverable() {
let recoverable = MediaError::new(
DeviceType::Microphone,
MediaErrorKind::DeviceInUse,
"Device busy",
);
assert!(recoverable.recoverable);
let not_recoverable = MediaError::new(
DeviceType::Camera,
MediaErrorKind::NotSupported,
"No camera support",
);
assert!(!not_recoverable.recoverable);
}
#[test]
fn call_snapshot_helpers() {
let mut snapshot = CallSnapshot::default();
assert!(!snapshot.is_in_call());
assert!(!snapshot.has_critical_errors());
assert!(!snapshot.is_screen_sharing);
snapshot.state = CallState::InCall;
assert!(snapshot.is_in_call());
snapshot.media_errors.push(MediaError::new(
DeviceType::Microphone,
MediaErrorKind::NotSupported,
"No microphone",
));
assert!(snapshot.has_critical_errors());
}
#[test]
fn call_settings_default() {
let settings = CallSettings::default();
assert!(settings.selected_microphone.is_none());
assert!(settings.selected_speaker.is_none());
assert!(settings.selected_camera.is_none());
assert!(!settings.auto_mute_on_join);
assert!(!settings.noise_suppression);
assert!(!settings.recording_enabled);
assert!(!settings.recording_include_video);
assert!(settings.recording_directory.is_none());
}
#[test]
fn connection_quality_labels() {
assert_eq!(ConnectionQuality::Unknown.label(), "Unknown");
assert_eq!(ConnectionQuality::Excellent.label(), "Excellent");
assert_eq!(ConnectionQuality::Good.label(), "Good");
assert_eq!(ConnectionQuality::Fair.label(), "Fair");
assert_eq!(ConnectionQuality::Poor.label(), "Poor");
assert_eq!(ConnectionQuality::Critical.label(), "Critical");
}
#[test]
fn connection_quality_signal_bars() {
assert_eq!(ConnectionQuality::Unknown.signal_bars(), 0);
assert_eq!(ConnectionQuality::Critical.signal_bars(), 1);
assert_eq!(ConnectionQuality::Poor.signal_bars(), 2);
assert_eq!(ConnectionQuality::Fair.signal_bars(), 3);
assert_eq!(ConnectionQuality::Good.signal_bars(), 4);
assert_eq!(ConnectionQuality::Excellent.signal_bars(), 5);
}
#[test]
fn connection_quality_from_metrics() {
assert_eq!(
ConnectionQuality::from_metrics(30, 0.5),
ConnectionQuality::Excellent
);
assert_eq!(
ConnectionQuality::from_metrics(80, 0.8),
ConnectionQuality::Good
);
assert_eq!(
ConnectionQuality::from_metrics(200, 1.5),
ConnectionQuality::Fair
);
assert_eq!(
ConnectionQuality::from_metrics(350, 3.0),
ConnectionQuality::Poor
);
assert_eq!(
ConnectionQuality::from_metrics(600, 2.0),
ConnectionQuality::Critical
);
assert_eq!(
ConnectionQuality::from_metrics(100, 15.0),
ConnectionQuality::Critical
);
}
#[test]
fn quality_metrics_default() {
let metrics = QualityMetrics::default();
assert_eq!(metrics.latency_ms, 0);
assert_eq!(metrics.packet_loss_percent, 0.0);
assert_eq!(metrics.quality, ConnectionQuality::Unknown);
assert!(!metrics.has_video());
}
#[test]
fn quality_metrics_video_detection() {
let mut metrics = QualityMetrics::default();
assert!(!metrics.has_video());
assert_eq!(metrics.video_resolution(), "None");
metrics.video_width = 1920;
metrics.video_height = 1080;
assert!(metrics.has_video());
assert_eq!(metrics.video_resolution(), "1920x1080");
}
#[test]
fn quality_metrics_total_bitrate() {
let metrics = QualityMetrics {
audio_bitrate_kbps: 32,
video_bitrate_kbps: 1500,
..Default::default()
};
assert_eq!(metrics.total_bitrate_kbps(), 1532);
}
#[test]
fn quality_metrics_recalculate() {
let mut metrics = QualityMetrics {
latency_ms: 200,
packet_loss_percent: 3.0,
..Default::default()
};
metrics.recalculate_quality();
assert_eq!(metrics.quality, ConnectionQuality::Fair);
}
#[test]
fn call_snapshot_quality_helpers() {
let mut snapshot = CallSnapshot::default();
assert_eq!(snapshot.connection_quality(), ConnectionQuality::Unknown);
assert!(!snapshot.has_quality_issues());
snapshot.quality_metrics.quality = ConnectionQuality::Poor;
assert!(snapshot.has_quality_issues());
snapshot.quality_metrics.quality = ConnectionQuality::Critical;
assert!(snapshot.has_quality_issues());
snapshot.quality_metrics.quality = ConnectionQuality::Good;
assert!(!snapshot.has_quality_issues());
}
#[test]
fn call_snapshot_participant_quality() {
let mut snapshot = CallSnapshot::default();
snapshot.participant_quality.push(ParticipantQuality {
participant_id: "alice".to_string(),
incoming: QualityMetrics {
latency_ms: 50,
packet_loss_percent: 0.5,
quality: ConnectionQuality::Excellent,
..Default::default()
},
outgoing: None,
});
assert!(snapshot.get_participant_quality("alice").is_some());
assert!(snapshot.get_participant_quality("bob").is_none());
let quality = snapshot.get_participant_quality("alice").unwrap();
assert_eq!(quality.incoming.quality, ConnectionQuality::Excellent);
}
#[test]
fn recording_state_is_active() {
assert!(!RecordingState::NotRecording.is_active());
assert!(RecordingState::Recording.is_active());
assert!(RecordingState::Paused.is_active());
assert!(RecordingState::Finalizing.is_active());
}
#[test]
fn recording_state_labels() {
assert_eq!(RecordingState::NotRecording.label(), "Not Recording");
assert_eq!(RecordingState::Recording.label(), "Recording");
assert_eq!(RecordingState::Paused.label(), "Paused");
assert_eq!(RecordingState::Finalizing.label(), "Saving...");
}
#[test]
fn recording_state_status_class() {
assert_eq!(
RecordingState::NotRecording.status_class(),
"text-slate-400"
);
assert_eq!(
RecordingState::Recording.status_class(),
"text-red-500 animate-pulse"
);
assert_eq!(RecordingState::Paused.status_class(), "text-amber-500");
assert_eq!(RecordingState::Finalizing.status_class(), "text-blue-500");
}
#[test]
fn recording_info_formatted_duration() {
let info = RecordingInfo {
duration_ms: 65_000, ..Default::default()
};
assert_eq!(info.formatted_duration(), "01:05");
let info2 = RecordingInfo {
duration_ms: 3_661_000, ..Default::default()
};
assert_eq!(info2.formatted_duration(), "61:01");
}
#[test]
fn recording_info_formatted_size() {
let bytes_info = RecordingInfo {
file_size_bytes: 500,
..Default::default()
};
assert_eq!(bytes_info.formatted_size(), "500 B");
let kb_info = RecordingInfo {
file_size_bytes: 2048,
..Default::default()
};
assert_eq!(kb_info.formatted_size(), "2.0 KB");
let mb_info = RecordingInfo {
file_size_bytes: 5 * 1024 * 1024,
..Default::default()
};
assert_eq!(mb_info.formatted_size(), "5.0 MB");
let gb_info = RecordingInfo {
file_size_bytes: 2 * 1024 * 1024 * 1024,
..Default::default()
};
assert_eq!(gb_info.formatted_size(), "2.00 GB");
}
#[test]
fn recording_info_default() {
let info = RecordingInfo::default();
assert_eq!(info.state, RecordingState::NotRecording);
assert!(info.includes_audio);
assert!(!info.includes_video);
assert!(!info.includes_screen);
}
#[test]
fn call_snapshot_recording_helpers() {
let mut snapshot = CallSnapshot::default();
assert_eq!(snapshot.recording_state(), RecordingState::NotRecording);
assert!(!snapshot.is_recording_active());
assert!(snapshot.recording_duration().is_none());
snapshot.is_recording = true;
snapshot.recording_info = Some(RecordingInfo {
state: RecordingState::Recording,
duration_ms: 30_000,
..Default::default()
});
assert_eq!(snapshot.recording_state(), RecordingState::Recording);
assert!(snapshot.is_recording_active());
assert_eq!(snapshot.recording_duration(), Some("00:30".to_string()));
snapshot.recording_info.as_mut().unwrap().state = RecordingState::Paused;
assert_eq!(snapshot.recording_state(), RecordingState::Paused);
assert!(snapshot.is_recording_active());
}
#[test]
fn call_type_labels_and_limits() {
assert_eq!(CallType::Direct.label(), "Call");
assert_eq!(CallType::Group.label(), "Group Call");
assert_eq!(CallType::Channel.label(), "Voice Channel");
assert_eq!(CallType::Direct.default_max_participants(), 2);
assert_eq!(CallType::Group.default_max_participants(), 25);
assert_eq!(CallType::Channel.default_max_participants(), 100);
assert!(!CallType::Direct.has_host_controls());
assert!(CallType::Group.has_host_controls());
assert!(CallType::Channel.has_host_controls());
}
#[test]
fn participant_role_permissions() {
let participant = ParticipantRole::Participant;
assert!(!participant.can_mute_others());
assert!(!participant.can_remove_participants());
assert!(!participant.can_promote());
assert!(!participant.can_end_call());
assert!(!participant.can_manage_recording());
let cohost = ParticipantRole::CoHost;
assert!(cohost.can_mute_others());
assert!(cohost.can_remove_participants());
assert!(!cohost.can_promote());
assert!(!cohost.can_end_call());
assert!(cohost.can_manage_recording());
let host = ParticipantRole::Host;
assert!(host.can_mute_others());
assert!(host.can_remove_participants());
assert!(host.can_promote());
assert!(host.can_end_call());
assert!(host.can_manage_recording());
}
#[test]
fn participant_role_labels() {
assert_eq!(ParticipantRole::Participant.label(), "Participant");
assert_eq!(ParticipantRole::CoHost.label(), "Co-Host");
assert_eq!(ParticipantRole::Host.label(), "Host");
}
#[test]
fn participant_role_helpers() {
let mut p = Participant {
id: "p1".to_string(),
display_name: "Alice".to_string(),
four_words: "a-b-c-d".to_string(),
role: ParticipantRole::Participant,
is_muted: true,
is_muted_by_host: false,
is_video_enabled: false,
is_speaking: false,
is_screen_sharing: false,
hand_raised: false,
audio_level: 0.0,
joined_at: 0,
};
assert!(!p.is_host());
assert!(!p.has_elevated_role());
assert!(p.can_self_unmute());
p.is_muted_by_host = true;
assert!(!p.can_self_unmute());
p.role = ParticipantRole::CoHost;
assert!(!p.is_host());
assert!(p.has_elevated_role());
assert!(p.can_self_unmute());
p.role = ParticipantRole::Host;
assert!(p.is_host());
assert!(p.has_elevated_role());
}
#[test]
fn call_info_group_helpers() {
let call = CallInfo {
call_id: "call1".to_string(),
entity_id: "ent1".to_string(),
entity_name: "Team Chat".to_string(),
call_type: CallType::Group,
participants: vec![
Participant {
id: "host".to_string(),
display_name: "Host".to_string(),
four_words: "a-b-c-d".to_string(),
role: ParticipantRole::Host,
is_muted: false,
is_muted_by_host: false,
is_video_enabled: false,
is_speaking: false,
is_screen_sharing: false,
hand_raised: false,
audio_level: 0.0,
joined_at: 0,
},
Participant {
id: "cohost".to_string(),
display_name: "CoHost".to_string(),
four_words: "e-f-g-h".to_string(),
role: ParticipantRole::CoHost,
is_muted: false,
is_muted_by_host: false,
is_video_enabled: false,
is_speaking: false,
is_screen_sharing: false,
hand_raised: true,
audio_level: 0.0,
joined_at: 0,
},
Participant {
id: "participant".to_string(),
display_name: "Participant".to_string(),
four_words: "i-j-k-l".to_string(),
role: ParticipantRole::Participant,
is_muted: false,
is_muted_by_host: false,
is_video_enabled: false,
is_speaking: false,
is_screen_sharing: false,
hand_raised: true,
audio_level: 0.0,
joined_at: 0,
},
],
started_at: 0,
duration_seconds: 0,
my_participant_id: "host".to_string(),
host_id: "host".to_string(),
max_participants: 25,
is_locked: false,
mute_on_entry: false,
};
assert!(call.is_group_call());
assert!(call.am_i_host());
assert!(call.am_i_elevated());
assert_eq!(call.my_role(), ParticipantRole::Host);
assert!(call.host().is_some());
assert_eq!(call.host().unwrap().id, "host");
assert!(call.can_accept_more_participants());
assert_eq!(call.elevated_participants().len(), 2);
assert_eq!(call.participants_with_raised_hands().len(), 2);
}
#[test]
fn call_info_locked_and_full() {
let call = CallInfo {
call_id: "call1".to_string(),
entity_id: "ent1".to_string(),
entity_name: "Full Call".to_string(),
call_type: CallType::Direct,
participants: vec![
Participant {
id: "p1".to_string(),
display_name: "P1".to_string(),
four_words: "a-b-c-d".to_string(),
role: ParticipantRole::Host,
is_muted: false,
is_muted_by_host: false,
is_video_enabled: false,
is_speaking: false,
is_screen_sharing: false,
hand_raised: false,
audio_level: 0.0,
joined_at: 0,
},
Participant {
id: "p2".to_string(),
display_name: "P2".to_string(),
four_words: "e-f-g-h".to_string(),
role: ParticipantRole::Participant,
is_muted: false,
is_muted_by_host: false,
is_video_enabled: false,
is_speaking: false,
is_screen_sharing: false,
hand_raised: false,
audio_level: 0.0,
joined_at: 0,
},
],
started_at: 0,
duration_seconds: 0,
my_participant_id: "p1".to_string(),
host_id: "p1".to_string(),
max_participants: 2,
is_locked: false,
mute_on_entry: false,
};
assert!(!call.can_accept_more_participants());
let locked_call = CallInfo {
is_locked: true,
max_participants: 25,
..call
};
assert!(!locked_call.can_accept_more_participants());
}
#[test]
fn call_outcome_labels() {
assert_eq!(CallOutcome::Completed.label(), "Completed");
assert_eq!(CallOutcome::NoAnswer.label(), "No Answer");
assert_eq!(CallOutcome::Declined.label(), "Declined");
assert_eq!(CallOutcome::Missed.label(), "Missed");
assert_eq!(CallOutcome::Failed.label(), "Failed");
assert_eq!(CallOutcome::Cancelled.label(), "Cancelled");
assert_eq!(CallOutcome::InProgress.label(), "In Progress");
}
#[test]
fn call_outcome_is_missed() {
assert!(CallOutcome::Missed.is_missed());
assert!(!CallOutcome::Completed.is_missed());
assert!(!CallOutcome::NoAnswer.is_missed());
}
#[test]
fn call_outcome_was_connected() {
assert!(CallOutcome::Completed.was_connected());
assert!(CallOutcome::InProgress.was_connected());
assert!(!CallOutcome::Missed.was_connected());
assert!(!CallOutcome::NoAnswer.was_connected());
assert!(!CallOutcome::Failed.was_connected());
}
#[test]
fn call_direction_labels() {
assert_eq!(CallDirection::Outgoing.label(), "Outgoing");
assert_eq!(CallDirection::Incoming.label(), "Incoming");
}
#[test]
fn call_history_entry_new_outgoing() {
let entry = CallHistoryEntry::new_outgoing(
"call1".to_string(),
"ent1".to_string(),
"Test Entity".to_string(),
CallType::Direct,
);
assert_eq!(entry.call_id, "call1");
assert_eq!(entry.direction, CallDirection::Outgoing);
assert_eq!(entry.outcome, CallOutcome::InProgress);
assert!(entry.is_read); assert!(entry.started_at > 0);
}
#[test]
fn call_history_entry_new_incoming() {
let entry = CallHistoryEntry::new_incoming(
"call1".to_string(),
"ent1".to_string(),
"Test Entity".to_string(),
CallType::Direct,
);
assert_eq!(entry.direction, CallDirection::Incoming);
assert!(!entry.is_read); }
#[test]
fn call_history_entry_finalize() {
let mut entry = CallHistoryEntry::new_outgoing(
"call1".to_string(),
"ent1".to_string(),
"Test".to_string(),
CallType::Direct,
);
entry.started_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
- 5000;
entry.finalize(CallOutcome::Completed);
assert_eq!(entry.outcome, CallOutcome::Completed);
assert!(entry.ended_at > entry.started_at);
assert!(entry.duration_seconds >= 4); }
#[test]
fn call_history_entry_formatted_duration() {
let mut entry = CallHistoryEntry::new_outgoing(
"call1".to_string(),
"ent1".to_string(),
"Test".to_string(),
CallType::Direct,
);
entry.duration_seconds = 0;
assert_eq!(entry.formatted_duration(), "0:00");
entry.duration_seconds = 65;
assert_eq!(entry.formatted_duration(), "1:05");
entry.duration_seconds = 3723; assert_eq!(entry.formatted_duration(), "1:02:03");
}
#[test]
fn call_history_entry_participants_display() {
let mut entry = CallHistoryEntry::new_outgoing(
"call1".to_string(),
"ent1".to_string(),
"Group Chat".to_string(),
CallType::Group,
);
assert_eq!(entry.participants_display(), "Group Chat");
entry.participants.push(HistoryParticipant {
id: "p1".to_string(),
display_name: "Alice".to_string(),
four_words: "a-b-c-d".to_string(),
duration_seconds: 60,
partial_participation: false,
});
assert_eq!(entry.participants_display(), "Alice");
entry.participants.push(HistoryParticipant {
id: "p2".to_string(),
display_name: "Bob".to_string(),
four_words: "e-f-g-h".to_string(),
duration_seconds: 60,
partial_participation: false,
});
assert_eq!(entry.participants_display(), "Alice and Bob");
entry.participants.push(HistoryParticipant {
id: "p3".to_string(),
display_name: "Charlie".to_string(),
four_words: "i-j-k-l".to_string(),
duration_seconds: 60,
partial_participation: false,
});
assert_eq!(entry.participants_display(), "Alice and 2 others");
}
#[test]
fn call_history_entry_is_unread_missed() {
let mut entry = CallHistoryEntry::new_incoming(
"call1".to_string(),
"ent1".to_string(),
"Test".to_string(),
CallType::Direct,
);
entry.outcome = CallOutcome::Missed;
entry.is_read = false;
assert!(entry.is_unread_missed());
entry.mark_read();
assert!(!entry.is_unread_missed());
}
#[test]
fn call_history_add_and_get() {
let mut history = CallHistory::new(100);
assert!(history.is_empty());
let entry1 = CallHistoryEntry::new_outgoing(
"call1".to_string(),
"ent1".to_string(),
"Test 1".to_string(),
CallType::Direct,
);
let entry2 = CallHistoryEntry::new_outgoing(
"call2".to_string(),
"ent2".to_string(),
"Test 2".to_string(),
CallType::Direct,
);
history.add(entry1);
history.add(entry2);
assert_eq!(history.len(), 2);
assert_eq!(history.entries[0].call_id, "call2"); assert_eq!(history.entries[1].call_id, "call1");
assert!(history.get("call1").is_some());
assert!(history.get("nonexistent").is_none());
}
#[test]
fn call_history_max_entries() {
let mut history = CallHistory::new(2);
for i in 0..5 {
let entry = CallHistoryEntry::new_outgoing(
format!("call{}", i),
"ent".to_string(),
"Test".to_string(),
CallType::Direct,
);
history.add(entry);
}
assert_eq!(history.len(), 2);
assert_eq!(history.entries[0].call_id, "call4");
assert_eq!(history.entries[1].call_id, "call3");
}
#[test]
fn call_history_update() {
let mut history = CallHistory::new(100);
let entry = CallHistoryEntry::new_outgoing(
"call1".to_string(),
"ent1".to_string(),
"Test".to_string(),
CallType::Direct,
);
history.add(entry);
history.update("call1", |e| {
e.outcome = CallOutcome::Completed;
e.duration_seconds = 120;
});
let updated = history.get("call1").unwrap();
assert_eq!(updated.outcome, CallOutcome::Completed);
assert_eq!(updated.duration_seconds, 120);
}
#[test]
fn call_history_remove() {
let mut history = CallHistory::new(100);
let entry = CallHistoryEntry::new_outgoing(
"call1".to_string(),
"ent1".to_string(),
"Test".to_string(),
CallType::Direct,
);
history.add(entry);
let removed = history.remove("call1");
assert!(removed.is_some());
assert!(history.is_empty());
let not_found = history.remove("nonexistent");
assert!(not_found.is_none());
}
#[test]
fn call_history_missed_calls() {
let mut history = CallHistory::new(100);
let mut missed = CallHistoryEntry::new_incoming(
"call1".to_string(),
"ent1".to_string(),
"Missed".to_string(),
CallType::Direct,
);
missed.outcome = CallOutcome::Missed;
missed.is_read = false;
let completed = CallHistoryEntry::new_outgoing(
"call2".to_string(),
"ent1".to_string(),
"Completed".to_string(),
CallType::Direct,
);
history.add(missed);
history.add(completed);
assert_eq!(history.missed_calls().len(), 1);
assert_eq!(history.unread_missed_count(), 1);
history.mark_all_read();
assert_eq!(history.unread_missed_count(), 0);
}
#[test]
fn call_history_for_entity() {
let mut history = CallHistory::new(100);
let entry1 = CallHistoryEntry::new_outgoing(
"call1".to_string(),
"ent1".to_string(),
"Entity 1".to_string(),
CallType::Direct,
);
let entry2 = CallHistoryEntry::new_outgoing(
"call2".to_string(),
"ent2".to_string(),
"Entity 2".to_string(),
CallType::Direct,
);
let entry3 = CallHistoryEntry::new_outgoing(
"call3".to_string(),
"ent1".to_string(),
"Entity 1 Again".to_string(),
CallType::Direct,
);
history.add(entry1);
history.add(entry2);
history.add(entry3);
let ent1_calls = history.for_entity("ent1");
assert_eq!(ent1_calls.len(), 2);
}
#[test]
fn call_history_recent() {
let mut history = CallHistory::new(100);
for i in 0..10 {
let entry = CallHistoryEntry::new_outgoing(
format!("call{}", i),
"ent".to_string(),
"Test".to_string(),
CallType::Direct,
);
history.add(entry);
}
let recent = history.recent(3);
assert_eq!(recent.len(), 3);
assert_eq!(recent[0].call_id, "call9"); }
#[test]
fn pending_call_invite_new() {
let invite = PendingCallInvite::new(
"call-123".to_string(),
"caller-456".to_string(),
"Alice".to_string(),
"group-789".to_string(),
CallType::Group,
);
assert_eq!(invite.call_id, "call-123");
assert_eq!(invite.caller_id, "caller-456");
assert_eq!(invite.caller_name, "Alice");
assert_eq!(invite.entity_id, "group-789");
assert_eq!(invite.call_type, CallType::Group);
assert!(invite.id.starts_with("invite-call-123-"));
assert!(invite.expires_at > invite.received_at);
assert_eq!(
invite.expires_at - invite.received_at,
PENDING_INVITE_EXPIRY_MS
);
}
#[test]
fn pending_call_invite_is_expired() {
let mut invite = PendingCallInvite::new(
"call-1".to_string(),
"caller-1".to_string(),
"Test".to_string(),
"entity-1".to_string(),
CallType::Direct,
);
assert!(!invite.is_expired());
invite.expires_at = invite.received_at - 1000;
assert!(invite.is_expired());
}
#[test]
fn pending_call_invite_time_remaining() {
let mut invite = PendingCallInvite::new(
"call-1".to_string(),
"caller-1".to_string(),
"Test".to_string(),
"entity-1".to_string(),
CallType::Direct,
);
let remaining = invite.time_remaining();
assert!(!remaining.contains("Expired"));
invite.expires_at = invite.received_at - 1000;
assert_eq!(invite.time_remaining(), "Expired");
}
#[test]
fn pending_invites_snapshot_default() {
let snapshot = PendingInvitesSnapshot::default();
assert!(snapshot.invites.is_empty());
assert_eq!(snapshot.count, 0);
assert!(!snapshot.has_invites());
}
#[test]
fn pending_invites_snapshot_has_invites() {
let mut snapshot = PendingInvitesSnapshot::default();
assert!(!snapshot.has_invites());
snapshot.invites.push(PendingCallInvite::new(
"call-1".to_string(),
"caller-1".to_string(),
"Alice".to_string(),
"entity-1".to_string(),
CallType::Direct,
));
snapshot.count = 1;
assert!(snapshot.has_invites());
}
#[test]
fn pending_invites_snapshot_for_caller() {
let mut snapshot = PendingInvitesSnapshot::default();
snapshot.invites.push(PendingCallInvite::new(
"call-1".to_string(),
"alice".to_string(),
"Alice".to_string(),
"entity-1".to_string(),
CallType::Direct,
));
snapshot.invites.push(PendingCallInvite::new(
"call-2".to_string(),
"bob".to_string(),
"Bob".to_string(),
"entity-2".to_string(),
CallType::Direct,
));
snapshot.invites.push(PendingCallInvite::new(
"call-3".to_string(),
"alice".to_string(),
"Alice".to_string(),
"entity-3".to_string(),
CallType::Group,
));
let alice_invites = snapshot.for_caller("alice");
assert_eq!(alice_invites.len(), 2);
let bob_invites = snapshot.for_caller("bob");
assert_eq!(bob_invites.len(), 1);
let charlie_invites = snapshot.for_caller("charlie");
assert!(charlie_invites.is_empty());
}
#[test]
fn pending_invites_snapshot_most_urgent() {
let snapshot = PendingInvitesSnapshot::default();
assert!(snapshot.most_urgent().is_none());
let mut snapshot_with_invites = PendingInvitesSnapshot::default();
snapshot_with_invites.invites.push(PendingCallInvite::new(
"call-1".to_string(),
"caller-1".to_string(),
"Alice".to_string(),
"entity-1".to_string(),
CallType::Direct,
));
let urgent = snapshot_with_invites.most_urgent();
assert!(urgent.is_some());
assert_eq!(urgent.unwrap().call_id, "call-1");
}
#[test]
fn pending_invites_snapshot_active() {
let mut snapshot = PendingInvitesSnapshot::default();
let mut expired_invite = PendingCallInvite::new(
"call-expired".to_string(),
"caller-1".to_string(),
"Expired".to_string(),
"entity-1".to_string(),
CallType::Direct,
);
expired_invite.expires_at = expired_invite.received_at - 1000;
let fresh_invite = PendingCallInvite::new(
"call-fresh".to_string(),
"caller-2".to_string(),
"Fresh".to_string(),
"entity-2".to_string(),
CallType::Direct,
);
snapshot.invites.push(expired_invite);
snapshot.invites.push(fresh_invite);
let active = snapshot.active();
assert_eq!(active.len(), 1);
assert_eq!(active[0].call_id, "call-fresh");
}
#[test]
fn pending_invite_constants() {
assert_eq!(MAX_PENDING_INVITES, 10);
assert_eq!(PENDING_INVITE_EXPIRY_MS, 5 * 60 * 1000); }
}