2026-09-19 15:43:37 +02:00
using UIKit ;
2026-09-19 19:33:10 +02:00
using VoiceCat.Audio ;
2026-09-19 15:43:37 +02:00
using Voicecat.V1 ;
using Channel = Voicecat . V1 . Channel ;
namespace VoiceCat.iOS ;
internal sealed class MainTabController : UITabBarController
{
internal MainTabController ( AppModel model )
{
2026-09-19 19:33:10 +02:00
UIViewController channels = Wrap ( new ChannelsController ( model ), "Channels" , "list.bullet.indent" , 0 );
UIViewController chat = Wrap ( new ChatController ( model ), "Chat" , "message" , 1 );
UIViewController settings = Wrap ( new SettingsController ( model ), "Settings" , "gear" , 2 );
2026-09-19 15:43:37 +02:00
ViewControllers = [ channels , chat , settings ];
}
2026-09-19 19:33:10 +02:00
private static UIViewController Wrap ( UIViewController content , string title , string image , nint tag )
{
var root = new UIViewController (); UIView rootView = root . View !; rootView . BackgroundColor = UIColor . SystemBackground ;
var navigation = new UINavigationController ( content ); UIView navigationView = navigation . View !; navigationView . TranslatesAutoresizingMaskIntoConstraints = false ;
root . AddChildViewController ( navigation ); rootView . AddSubview ( navigationView ); navigation . DidMoveToParentViewController ( root );
var voice = new VoiceControlsView ( AppModel . Shared ) { TranslatesAutoresizingMaskIntoConstraints = false }; rootView . AddSubview ( voice );
NSLayoutConstraint . ActivateConstraints ([ navigationView . TopAnchor . ConstraintEqualTo ( rootView . TopAnchor ), navigationView . LeadingAnchor . ConstraintEqualTo ( rootView . LeadingAnchor ),
navigationView . TrailingAnchor . ConstraintEqualTo ( rootView . TrailingAnchor ), navigationView . BottomAnchor . ConstraintEqualTo ( voice . TopAnchor ),
voice . LeadingAnchor . ConstraintEqualTo ( rootView . LeadingAnchor ), voice . TrailingAnchor . ConstraintEqualTo ( rootView . TrailingAnchor ),
voice . BottomAnchor . ConstraintEqualTo ( rootView . SafeAreaLayoutGuide . BottomAnchor ), voice . HeightAnchor . ConstraintEqualTo ( 58 )]);
root . TabBarItem = new ( title , UIImage . GetSystemImage ( image ), tag ); return root ;
}
}
internal sealed class VoiceControlsView : UIView
{
private readonly AppModel model ; private readonly UIButton join = UIButton . FromType ( UIButtonType . System );
private readonly UIButton ptt = UIButton . FromType ( UIButtonType . System ); private readonly UIButton mute = UIButton . FromType ( UIButtonType . System );
private readonly UIButton deafen = UIButton . FromType ( UIButtonType . System ); private readonly UIProgressView level = new ( UIProgressViewStyle . Default );
internal VoiceControlsView ( AppModel model ) { this . model = model ; model . Changed += Refresh ; Build (); }
private void Build ()
{
BackgroundColor = UIColor . SecondarySystemBackground ; join . TouchUpInside += async ( _ , _ ) => await Run ( model . ToggleVoiceAsync );
ptt . TouchDown += ( _ , _ ) => model . SetPushToTalk ( true ); ptt . TouchUpInside += ( _ , _ ) => model . SetPushToTalk ( false );
ptt . TouchUpOutside += ( _ , _ ) => model . SetPushToTalk ( false ); ptt . TouchCancel += ( _ , _ ) => model . SetPushToTalk ( false );
mute . TouchUpInside += ( _ , _ ) => model . SetSelfAudio (!( model . Client ?. Audio . MicMuted ?? false ), model . Client ?. Audio . Deafened ?? false );
deafen . TouchUpInside += ( _ , _ ) => model . SetSelfAudio ( model . Client ?. Audio . MicMuted ?? false , !( model . Client ?. Audio . Deafened ?? false ));
UIStackView stack = new ([ join , ptt , level , mute , deafen ]) { Axis = UILayoutConstraintAxis . Horizontal , Alignment = UIStackViewAlignment . Center ,
Distribution = UIStackViewDistribution . Fill , Spacing = 12 , TranslatesAutoresizingMaskIntoConstraints = false };
AddSubview ( stack ); level . WidthAnchor . ConstraintEqualTo ( 65 ). Active = true ;
NSLayoutConstraint . ActivateConstraints ([ stack . LeadingAnchor . ConstraintEqualTo ( LeadingAnchor , 12 ), stack . TrailingAnchor . ConstraintEqualTo ( TrailingAnchor , - 12 ), stack . CenterYAnchor . ConstraintEqualTo ( CenterYAnchor )]); Refresh ();
}
private void Refresh ()
{
join . SetTitle ( model . VoiceJoined ? "Leave Voice" : "Join Voice" , UIControlState . Normal ); join . AccessibilityLabel = join . Title ( UIControlState . Normal ); join . Enabled = model . CurrentChannelId != 0 ;
ptt . SetTitle ( "Hold to Talk" , UIControlState . Normal ); ptt . AccessibilityLabel = "Push to talk, hold to transmit" ; ptt . Hidden = model . Settings . InputMode != AudioInputMode . PushToTalk ; ptt . Enabled = model . VoiceJoined ;
bool muted = model . Client ?. Audio . MicMuted == true , deafened = model . Client ?. Audio . Deafened == true ;
mute . SetImage ( UIImage . GetSystemImage ( muted ? "mic.slash.fill" : "mic.fill" ), UIControlState . Normal ); mute . AccessibilityLabel = muted ? "Unmute microphone" : "Mute microphone" ; mute . Enabled = model . VoiceJoined ;
deafen . SetImage ( UIImage . GetSystemImage ( deafened ? "headphones.slash" : "headphones" ), UIControlState . Normal ); deafen . AccessibilityLabel = deafened ? "Undeafen" : "Deafen" ; deafen . Enabled = model . VoiceJoined ;
level . Progress = Math . Clamp ( model . MicrophoneLevel * 10 , 0 , 1 ); level . AccessibilityLabel = "Microphone level" ; level . AccessibilityValue = $"{level.Progress:P0}" ;
}
private async Task Run ( Func < Task > operation ) { try { await operation (); } catch ( Exception exception ) { if ( Window ?. RootViewController is { } owner ) UiHelpers . ShowError ( owner , exception ); } }
2026-09-19 15:43:37 +02:00
}
internal sealed class ChannelsController : UITableViewController
{
2026-09-19 19:33:10 +02:00
private readonly AppModel model ; private IReadOnlyList <( Channel Channel , int Depth )> Visible => Flatten ();
internal ChannelsController ( AppModel model ) { this . model = model ; Title = "Channels" ; model . Changed += Reload ; }
public override void ViewDidLoad ()
2026-09-19 15:43:37 +02:00
{
2026-09-19 19:33:10 +02:00
base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "channel" );
NavigationItem . RightBarButtonItems = [ new ( "Users" , UIBarButtonItemStyle . Plain , ( _ , _ ) => NavigationController ?. PushViewController ( new UsersController ( model ), true )),
new ( UIBarButtonSystemItem . Add , ( _ , _ ) => NavigationController ?. PushViewController ( new ChannelEditorController ( model , null ), true ))]; Reload ();
2026-09-19 15:43:37 +02:00
}
2026-09-19 19:33:10 +02:00
private void Reload () { TableView . ReloadData (); NavigationItem . RightBarButtonItems ![ 1 ]. Enabled = model . Client ?. Permissions is { } p && ( p . IsAdmin || p . CanCreateTempChannel ); }
2026-09-19 15:43:37 +02:00
public override nint RowsInSection ( UITableView tableView , nint section ) => Visible . Count ;
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath indexPath )
{
2026-09-19 19:33:10 +02:00
( Channel channel , int depth ) = Visible [ indexPath . Row ]; UITableViewCell cell = tableView . DequeueReusableCell ( "channel" , indexPath );
int count = model . Users . Count ( user => user . ChannelId == channel . Id ); var content = cell . DefaultContentConfiguration ;
content . Text = new string ( ' ' , depth * 3 ) + channel . Name ; content . SecondaryText = string . IsNullOrEmpty ( channel . Topic ) ? $"{count} users" : $"{channel.Topic} • {count} users" ;
content . Image = UIImage . GetSystemImage ( channel . Id == model . CurrentChannelId ? "checkmark.circle.fill" : channel . PasswordProtected ? "lock.fill" : "bubble.left" ); cell . ContentConfiguration = content ;
cell . AccessibilityLabel = $"{channel.Name}{(channel.Id == model.CurrentChannelId ? " , current " : "")}{(channel.PasswordProtected ? " , password protected " : "")}, {count} users" ; return cell ;
2026-09-19 15:43:37 +02:00
}
2026-09-19 19:33:10 +02:00
public override async void RowSelected ( UITableView tableView , NSIndexPath indexPath )
2026-09-19 15:43:37 +02:00
{
2026-09-19 19:33:10 +02:00
Channel channel = Visible [ indexPath . Row ]. Channel ; tableView . DeselectRow ( indexPath , true );
if ( channel . PasswordProtected ) { PromptPassword ( channel ); return ; }
try { await model . JoinChannelAsync ( channel . Id ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); }
}
public override UISwipeActionsConfiguration ? GetTrailingSwipeActionsConfiguration ( UITableView tableView , NSIndexPath indexPath )
{
if ( model . Client ?. Permissions . IsAdmin != true ) return null ; Channel channel = Visible [ indexPath . Row ]. Channel ;
UIContextualAction edit = UIContextualAction . FromContextualActionStyle ( UIContextualActionStyle . Normal , "Edit" , ( _ , _ , done ) => { NavigationController ?. PushViewController ( new ChannelEditorController ( model , channel ), true ); done ( true ); });
UIContextualAction delete = UIContextualAction . FromContextualActionStyle ( UIContextualActionStyle . Destructive , "Delete" , ( _ , _ , done ) => { ConfirmDelete ( channel ); done ( true ); });
delete . BackgroundColor = UIColor . SystemRed ; return UISwipeActionsConfiguration . FromActions ( channel . Id == 1 ? [ edit ] : [ delete , edit ]);
}
private void PromptPassword ( Channel channel )
{
UIAlertController prompt = UIAlertController . Create ( "Channel Password" , channel . Name , UIAlertControllerStyle . Alert ); prompt . AddTextField ( field => { field . SecureTextEntry = true ; field . AccessibilityLabel = "Channel password" ; });
prompt . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null )); prompt . AddAction ( UIAlertAction . Create ( "Join" , UIAlertActionStyle . Default , async _ => { try { await model . JoinChannelAsync ( channel . Id , prompt . TextFields ?[ 0 ]. Text ?? "" ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } })); PresentViewController ( prompt , true , null );
}
private void ConfirmDelete ( Channel channel )
{
UIAlertController alert = UIAlertController . Create ( "Delete channel?" , channel . Name , UIAlertControllerStyle . Alert ); alert . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null ));
alert . AddAction ( UIAlertAction . Create ( "Delete" , UIAlertActionStyle . Destructive , async _ => { try { GenericResult result = await model . RunAdminAsync ( client => client . DeleteChannelAsync ( channel . Id )); if (! result . Ok ) throw new InvalidOperationException ( result . Message ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } })); PresentViewController ( alert , true , null );
}
private IReadOnlyList <( Channel , int )> Flatten ()
{
var result = new List <( Channel , int )>(); void Add ( uint parent , int depth ) { foreach ( Channel value in model . Channels . Where ( channel => channel . ParentId == parent ). OrderBy ( channel => channel . Order ). ThenBy ( channel => channel . Name )) { result . Add (( value , depth )); Add ( value . Id , depth + 1 ); } }
Add ( 0 , 0 ); return result ;
2026-09-19 15:43:37 +02:00
}
}
internal sealed class ChatController : UIViewController
{
private readonly AppModel model ; private readonly UITextView log = new (); private readonly UITextField compose = UiHelpers . Field ( "Message" );
internal ChatController ( AppModel model ) { this . model = model ; Title = "Chat" ; model . Changed += Refresh ; }
public override void ViewDidLoad ()
{
2026-09-19 19:33:10 +02:00
base . ViewDidLoad (); View !. BackgroundColor = UIColor . SystemBackground ; log . Editable = false ; log . Font = UIFont . PreferredBody ; log . AccessibilityLabel = "Chat and activity timeline" ; log . TranslatesAutoresizingMaskIntoConstraints = false ;
2026-09-22 22:07:00 +02:00
NavigationItem . RightBarButtonItem = new ( "Private Chats" , UIBarButtonItemStyle . Plain , ( _ , _ ) => NavigationController ?. PushViewController ( new PrivateChatsController ( model ), true ));
2026-09-19 15:43:37 +02:00
UIButton send = UIButton . FromType ( UIButtonType . System ); send . SetTitle ( "Send" , UIControlState . Normal ); send . AccessibilityLabel = "Send message" ; send . TranslatesAutoresizingMaskIntoConstraints = false ; send . TouchUpInside += ( _ , _ ) => { model . SendText ( compose . Text ?? "" ); compose . Text = "" ; };
compose . TranslatesAutoresizingMaskIntoConstraints = false ; View . AddSubviews ( log , compose , send );
NSLayoutConstraint . ActivateConstraints ([ log . TopAnchor . ConstraintEqualTo ( View . SafeAreaLayoutGuide . TopAnchor ), log . LeadingAnchor . ConstraintEqualTo ( View . LeadingAnchor , 12 ), log . TrailingAnchor . ConstraintEqualTo ( View . TrailingAnchor , - 12 ), compose . TopAnchor . ConstraintEqualTo ( log . BottomAnchor , 8 ), compose . LeadingAnchor . ConstraintEqualTo ( View . LeadingAnchor , 12 ), compose . BottomAnchor . ConstraintEqualTo ( View . KeyboardLayoutGuide . TopAnchor , - 8 ), send . LeadingAnchor . ConstraintEqualTo ( compose . TrailingAnchor , 8 ), send . TrailingAnchor . ConstraintEqualTo ( View . TrailingAnchor , - 12 ), send . CenterYAnchor . ConstraintEqualTo ( compose . CenterYAnchor ), compose . WidthAnchor . ConstraintGreaterThanOrEqualTo ( 120 )]); Refresh ();
}
2026-09-19 19:33:10 +02:00
private void Refresh ()
2026-09-19 15:43:37 +02:00
{
2026-09-22 22:07:00 +02:00
IEnumerable <( DateTime Time , string Text )> chat = model . Messages . Where ( message => ! message . Private ). Select ( message => ( message . Timestamp , $"{message.Sender}: {message.Text}" ));
2026-09-19 19:33:10 +02:00
IEnumerable <( DateTime Time , string Text )> activity = model . Activity . Select ( value => ( value . Timestamp , $"• {value.Text}" ));
log . Text = string . Join ( "\n" , chat . Concat ( activity ). OrderBy ( value => value . Time ). Select ( value => $"[{value.Time:t}] {value.Text}" )); if ( log . Text . Length > 0 ) log . ScrollRangeToVisible ( new ( log . Text . Length - 1 , 1 ));
2026-09-19 15:43:37 +02:00
}
}
2026-09-22 22:07:00 +02:00
internal sealed class PrivateChatsController : UITableViewController
{
private readonly AppModel model ;
private IReadOnlyList <( uint Id , string Name , ChatEntry ? Latest , bool Online )> Peers
{
get
{
var online = model . Users . Where ( user => user . Id != model . SelfUserId ). ToDictionary ( user => user . Id );
var history = model . Messages . Where ( message => message . Private ). GroupBy ( message => message . PeerUserId ). ToDictionary ( group => group . Key , group => group . OrderByDescending ( message => message . Timestamp ). First ());
return online . Keys . Concat ( history . Keys ). Distinct (). Select ( id =>
{
online . TryGetValue ( id , out User ? user ); history . TryGetValue ( id , out ChatEntry ? latest );
string name = user ?. Nickname ?? latest ?. Peer ?? $"User {id}" ; return ( Id : id , Name : name , Latest : latest , Online : user is not null );
}). OrderByDescending ( peer => peer . Latest ?. Timestamp ?? DateTime . MinValue ). ThenBy ( peer => peer . Name ). ToArray ();
}
}
internal PrivateChatsController ( AppModel model ) { this . model = model ; Title = "Private Chats" ; model . Changed += Reload ; }
public override void ViewDidLoad () { base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "private-peer" ); Reload (); }
public override nint RowsInSection ( UITableView tableView , nint section ) => Peers . Count ;
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath indexPath )
{
( uint _ , string name , ChatEntry ? latest , bool online ) = Peers [ indexPath . Row ]; UITableViewCell cell = tableView . DequeueReusableCell ( "private-peer" , indexPath ); var content = cell . DefaultContentConfiguration ;
content . Text = name ; content . SecondaryText = latest ?. Text ?? "Start a conversation" ; content . Image = UIImage . GetSystemImage ( online ? "person.crop.circle.fill" : "person.crop.circle.badge.xmark" ); cell . ContentConfiguration = content ;
cell . Accessory = UITableViewCellAccessory . DisclosureIndicator ; cell . AccessibilityLabel = $"{name}, {(online ? " online " : " offline ")}, {content.SecondaryText}" ; return cell ;
}
public override void RowSelected ( UITableView tableView , NSIndexPath indexPath )
{
( uint id , string name , _ , _ ) = Peers [ indexPath . Row ]; tableView . DeselectRow ( indexPath , true ); NavigationController ?. PushViewController ( new PrivateConversationController ( model , id , name ), true );
}
private void Reload () { if ( IsViewLoaded ) { TableView . ReloadData (); TableView . BackgroundView = Peers . Count == 0 ? new UILabel { Text = "No other users or private conversations" , TextAlignment = UITextAlignment . Center , AccessibilityLabel = "No other users or private conversations" } : null ; } }
}
internal sealed class PrivateConversationController : UIViewController
{
private readonly AppModel model ; private readonly uint peerUserId ; private readonly string fallbackName ;
private readonly UITextView transcript = new (); private readonly UITextField compose = UiHelpers . Field ( "Private message" ); private readonly UIButton send = UIButton . FromType ( UIButtonType . System );
private User ? Peer => model . Users . FirstOrDefault ( user => user . Id == peerUserId );
internal PrivateConversationController ( AppModel model , uint peerUserId , string fallbackName )
{ this . model = model ; this . peerUserId = peerUserId ; this . fallbackName = fallbackName ; Title = fallbackName ; model . Changed += Refresh ; }
public override void ViewDidLoad ()
{
base . ViewDidLoad (); View !. BackgroundColor = UIColor . SystemBackground ; transcript . Editable = false ; transcript . Font = UIFont . PreferredBody ; transcript . AccessibilityLabel = $"Private conversation with {fallbackName}" ; transcript . TranslatesAutoresizingMaskIntoConstraints = false ;
compose . AccessibilityLabel = $"Message to {fallbackName}" ; compose . TranslatesAutoresizingMaskIntoConstraints = false ;
send . SetTitle ( "Send" , UIControlState . Normal ); send . AccessibilityLabel = $"Send message to {fallbackName}" ; send . TranslatesAutoresizingMaskIntoConstraints = false ; send . TouchUpInside += ( _ , _ ) => Send ();
NavigationItem . RightBarButtonItem = new ( "User" , UIBarButtonItemStyle . Plain , ( _ , _ ) => NavigationController ?. PushViewController ( new UserDetailController ( model , peerUserId ), true )) { AccessibilityLabel = $"Audio and user settings for {fallbackName}" };
View . AddSubviews ( transcript , compose , send );
NSLayoutConstraint . ActivateConstraints ([ transcript . TopAnchor . ConstraintEqualTo ( View . SafeAreaLayoutGuide . TopAnchor ), transcript . LeadingAnchor . ConstraintEqualTo ( View . LeadingAnchor , 12 ), transcript . TrailingAnchor . ConstraintEqualTo ( View . TrailingAnchor , - 12 ), compose . TopAnchor . ConstraintEqualTo ( transcript . BottomAnchor , 8 ), compose . LeadingAnchor . ConstraintEqualTo ( View . LeadingAnchor , 12 ), compose . BottomAnchor . ConstraintEqualTo ( View . KeyboardLayoutGuide . TopAnchor , - 8 ), send . LeadingAnchor . ConstraintEqualTo ( compose . TrailingAnchor , 8 ), send . TrailingAnchor . ConstraintEqualTo ( View . TrailingAnchor , - 12 ), send . CenterYAnchor . ConstraintEqualTo ( compose . CenterYAnchor ), compose . WidthAnchor . ConstraintGreaterThanOrEqualTo ( 120 )]); Refresh ();
}
private void Send () { string text = compose . Text ?? "" ; if ( string . IsNullOrWhiteSpace ( text ) || Peer is null ) return ; model . SendText ( text , peerUserId ); compose . Text = "" ; }
private void Refresh ()
{
if (! IsViewLoaded ) return ; string name = Peer ?. Nickname ?? fallbackName ; Title = name ;
IEnumerable < ChatEntry > messages = model . Messages . Where ( message => message . Private && message . PeerUserId == peerUserId ). OrderBy ( message => message . Timestamp );
transcript . Text = string . Join ( "\n" , messages . Select ( message => $"[{message.Timestamp:t}] {(message.SenderId == model.SelfUserId ? " You " : message.Sender)}: {message.Text}" ));
if ( transcript . Text . Length > 0 ) transcript . ScrollRangeToVisible ( new ( transcript . Text . Length - 1 , 1 ));
bool online = Peer is not null ; compose . Enabled = online ; send . Enabled = online ; NavigationItem . RightBarButtonItem !. Enabled = online ;
}
}