2026-09-19 19:33:10 +02:00
using UIKit ;
using Voicecat.V1 ;
using Channel = Voicecat . V1 . Channel ;
namespace VoiceCat.iOS ;
internal sealed class UsersController : UITableViewController
{
private readonly AppModel model ; private IReadOnlyList < User > Visible => model . CurrentChannelId == 0 ? model . Users : model . Users . Where ( user => user . ChannelId == model . CurrentChannelId ). ToArray ();
internal UsersController ( AppModel model ) { this . model = model ; Title = "Users" ; model . Changed += () => TableView . ReloadData (); }
public override void ViewDidLoad () { base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "user" ); }
public override nint RowsInSection ( UITableView tableView , nint section ) => Visible . Count ;
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath indexPath )
{
User user = Visible [ indexPath . Row ]; UITableViewCell cell = tableView . DequeueReusableCell ( "user" , indexPath ); var content = cell . DefaultContentConfiguration ;
content . Text = user . Nickname + ( user . Id == model . SelfUserId ? " (you)" : "" );
content . SecondaryText = user . ServerDeafened ? "server deafened" : user . ServerMuted ? "server muted" : user . SelfDeafened ? "deafened" : user . SelfMicMuted ? "muted" : user . IsGuest ? "guest" : "account" ;
content . Image = UIImage . GetSystemImage ( user . ServerMuted || user . SelfMicMuted ? "mic.slash.fill" : "mic.fill" ); cell . ContentConfiguration = content ; cell . AccessibilityLabel = $"{content.Text}, {content.SecondaryText}" ;
2026-09-22 22:07:00 +02:00
cell . Accessory = user . Id == model . SelfUserId ? UITableViewCellAccessory . None : UITableViewCellAccessory . DisclosureIndicator ;
if ( user . Id != model . SelfUserId ) cell . AccessibilityCustomActions =
[
new("Open user details", (Func<UIAccessibilityCustomAction, bool>)(_ => { Open(user.Id); return true; })),
new("Private message", (Func<UIAccessibilityCustomAction, bool>)(_ => { NavigationController?.PushViewController(new PrivateConversationController(model, user.Id, user.Nickname), true); return true; }))
] ;
return cell ;
2026-09-19 19:33:10 +02:00
}
public override void RowSelected ( UITableView tableView , NSIndexPath indexPath )
{
User user = Visible [ indexPath . Row ]; tableView . DeselectRow ( indexPath , true ); if ( user . Id == model . SelfUserId ) return ;
2026-09-22 22:07:00 +02:00
Open ( user . Id );
2026-09-19 19:33:10 +02:00
}
2026-09-22 22:07:00 +02:00
private void Open ( uint userId ) => NavigationController ?. PushViewController ( new UserDetailController ( model , userId ), true );
}
internal sealed class UserDetailController : FormController
{
private readonly AppModel model ; private readonly uint userId ;
private User ? User => model . Users . FirstOrDefault ( value => value . Id == userId );
private IReadOnlyList <( string Title , Action Run , bool Destructive )> AdminActions
2026-09-19 19:33:10 +02:00
{
2026-09-22 22:07:00 +02:00
get
{
User ? user = User ; if ( user is null ) return [];
Permissions permissions = model . Client ?. Permissions ?? new (); var result = new List <( string , Action , bool )>();
2026-09-19 19:33:10 +02:00
if ( permissions . CanKick || permissions . IsAdmin ) result . Add (( "Kick" , () => PromptReason ( user , false ), true ));
if ( permissions . CanBan || permissions . IsAdmin ) result . Add (( "Ban" , () => NavigationController ?. PushViewController ( new BanUserController ( model , user ), true ), true ));
if ( permissions . CanMoveUsers || permissions . IsAdmin ) result . Add (( "Move to channel" , () => NavigationController ?. PushViewController ( new MoveUserController ( model , user ), true ), false ));
if ( permissions . IsAdmin )
{
result . Add (( user . ServerMuted ? "Server unmute" : "Server mute" , () => Run (() => model . Client !. SetServerMuteAsync ( user . Id , ! user . ServerMuted , user . ServerDeafened )), false ));
result . Add (( user . ServerDeafened ? "Server undeafen" : "Server deafen" , () => Run (() => model . Client !. SetServerMuteAsync ( user . Id , user . ServerMuted , ! user . ServerDeafened )), false ));
result . Add (( "Permissions" , () => NavigationController ?. PushViewController ( new PermissionsController ( model , user ), true ), false ));
}
return result ;
2026-09-22 22:07:00 +02:00
}
2026-09-19 19:33:10 +02:00
}
2026-09-22 22:07:00 +02:00
internal UserDetailController ( AppModel model , uint userId ) : base ( model . Users . FirstOrDefault ( value => value . Id == userId )?. Nickname ?? $"User {userId}" )
{ this . model = model ; this . userId = userId ; model . Changed += Reload ; }
public override void ViewDidLoad () { base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "detail" ); Reload (); }
public override nint NumberOfSections ( UITableView tableView ) => AdminActions . Count == 0 ? 2 : 3 ;
public override nint RowsInSection ( UITableView tableView , nint section ) => section switch
{ 0 => Math . Max ( User ?. Streams . Count ?? 0 , 1 ), 1 => 1 , _ => AdminActions . Count };
public override string? TitleForHeader ( UITableView tableView , nint section ) => section switch { 0 => "Audio streams" , 1 => "Conversation" , _ => "Administration" };
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath indexPath )
{
User ? user = User ;
if ( indexPath . Section == 0 )
{
if ( user is null || user . Streams . Count == 0 )
{
UITableViewCell empty = TextCell ( tableView , indexPath , "detail" , user is null ? "User is offline" : "No active audio streams" );
empty . SelectionStyle = UITableViewCellSelectionStyle . None ; empty . AccessibilityTraits |= UIAccessibilityTrait . NotEnabled ; return empty ;
}
StreamInfo stream = user . Streams [ indexPath . Row ]; string kind = stream . Kind == StreamKind . StreamMic ? "Microphone" : "Screen audio" ;
( float Gain , bool Muted , bool NoiseReduction )? state = model . Client ?. Audio . GetRemotePlayback ( userId , stream . StreamId );
string status = state is null ? kind : $"{kind} · {state.Value.Gain:P0}{(state.Value.Muted ? " · muted " : "")}{(state.Value.NoiseReduction && stream.Kind == StreamKind.StreamMic ? " · noise reduction " : "")}" ;
string displayName = string . IsNullOrWhiteSpace ( stream . Label ) ? kind : stream . Label ; UITableViewCell cell = TextCell ( tableView , indexPath , "detail" , displayName , status );
cell . Accessory = UITableViewCellAccessory . DisclosureIndicator ; cell . AccessibilityLabel = $"{displayName}, {status}" ; return cell ;
}
if ( indexPath . Section == 1 )
{
UITableViewCell cell = TextCell ( tableView , indexPath , "detail" , "Private message" , user is null ? "User is offline" : null );
cell . Accessory = UITableViewCellAccessory . DisclosureIndicator ; cell . UserInteractionEnabled = user is not null ; if ( user is null ) cell . AccessibilityTraits |= UIAccessibilityTrait . NotEnabled ; return cell ;
}
( string title , _ , bool destructive ) = AdminActions [ indexPath . Row ]; UITableViewCell action = TextCell ( tableView , indexPath , "detail" , title );
if ( destructive ) { UIListContentConfiguration content = action . DefaultContentConfiguration ; content . TextProperties . Color = UIColor . SystemRed ; action . ContentConfiguration = content ; } return action ;
}
public override void RowSelected ( UITableView tableView , NSIndexPath indexPath )
{
tableView . DeselectRow ( indexPath , true ); User ? user = User ; if ( user is null ) return ;
if ( indexPath . Section == 0 && user . Streams . Count > indexPath . Row )
NavigationController ?. PushViewController ( new PerUserTuningController ( model , user . Id , user . Streams [ indexPath . Row ]. StreamId ), true );
else if ( indexPath . Section == 1 ) NavigationController ?. PushViewController ( new PrivateConversationController ( model , user . Id , user . Nickname ), true );
else if ( indexPath . Section == 2 ) AdminActions [ indexPath . Row ]. Run ();
}
private void Reload () { Title = User ?. Nickname ?? $"User {userId}" ; if ( IsViewLoaded ) TableView . ReloadData (); }
2026-09-19 19:33:10 +02:00
private void PromptReason ( User user , bool ban ) { UIAlertController prompt = UIAlertController . Create ( ban ? "Ban user" : "Kick user" , "Reason (optional)" , UIAlertControllerStyle . Alert ); prompt . AddTextField ( field => field . AccessibilityLabel = "Reason" ); prompt . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null )); prompt . AddAction ( UIAlertAction . Create ( ban ? "Ban" : "Kick" , UIAlertActionStyle . Destructive , _ => Run (() => model . Client !. KickUserAsync ( user . Id , prompt . TextFields ?[ 0 ]. Text ?? "" )))); PresentViewController ( prompt , true , null ); }
private async void Run ( Func < Task < GenericResult >> command ) { try { GenericResult result = await model . RunAdminAsync ( _ => command ()); if (! result . Ok ) throw new InvalidOperationException ( result . Message ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } }
}
internal abstract class FormController : UITableViewController
{
protected FormController ( string title ) : base ( UITableViewStyle . InsetGrouped ) { Title = title ; }
protected static UITableViewCell TextCell ( UITableView table , NSIndexPath path , string id , string text , string? detail = null )
{ UITableViewCell cell = table . DequeueReusableCell ( id , path ); var content = cell . DefaultContentConfiguration ; content . Text = text ; content . SecondaryText = detail ; cell . ContentConfiguration = content ; return cell ; }
protected static UISwitch Switch ( bool value , string label , EventHandler handler ) { var toggle = new UISwitch { On = value , AccessibilityLabel = label }; toggle . ValueChanged += handler ; return toggle ; }
}
internal sealed class PermissionsController : FormController
{
private readonly AppModel model ; private readonly User user ; private readonly string [] names = [ "Create temporary channels" , "Kick users" , "Ban users" , "Move users" , "Manage accounts" , "Administrator" ];
private readonly bool [] values = new bool [ 6 ];
internal PermissionsController ( AppModel model , User user ) : base ( $"Permissions — {user.Nickname}" ) { this . model = model ; this . user = user ; }
public override void ViewDidLoad () { base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "permission" ); NavigationItem . RightBarButtonItem = new ( "Save" , UIBarButtonItemStyle . Done , async ( _ , _ ) => await Save ()); }
public override nint RowsInSection ( UITableView tableView , nint section ) => names . Length ;
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath indexPath ) { UITableViewCell cell = TextCell ( tableView , indexPath , "permission" , names [ indexPath . Row ]); int row = indexPath . Row ; cell . AccessoryView = Switch ( values [ row ], names [ row ], ( _ , _ ) => values [ row ] = (( UISwitch ) cell . AccessoryView !). On ); return cell ; }
private async Task Save () { try { var permissions = new Permissions { CanCreateTempChannel = values [ 0 ], CanKick = values [ 1 ], CanBan = values [ 2 ], CanMoveUsers = values [ 3 ], CanAdminAccounts = values [ 4 ], IsAdmin = values [ 5 ] }; GenericResult result = await model . RunAdminAsync ( client => client . SetPermissionsAsync ( user . Id , permissions )); if (! result . Ok ) throw new InvalidOperationException ( result . Message ); NavigationController ?. PopViewController ( true ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } }
}
internal sealed class MoveUserController : FormController
{
private readonly AppModel model ; private readonly User user ; private uint selected ;
internal MoveUserController ( AppModel model , User user ) : base ( $"Move {user.Nickname}" ) { this . model = model ; this . user = user ; selected = user . ChannelId ; }
public override void ViewDidLoad () { base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "channel" ); NavigationItem . RightBarButtonItem = new ( "Move" , UIBarButtonItemStyle . Done , async ( _ , _ ) => await Move ()); }
public override nint RowsInSection ( UITableView tableView , nint section ) => model . Channels . Count ;
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath indexPath ) { Channel channel = model . Channels [ indexPath . Row ]; UITableViewCell cell = TextCell ( tableView , indexPath , "channel" , channel . Name ); cell . Accessory = channel . Id == selected ? UITableViewCellAccessory . Checkmark : UITableViewCellAccessory . None ; cell . AccessibilityLabel = channel . Name + ( channel . Id == selected ? ", selected" : "" ); return cell ; }
public override void RowSelected ( UITableView tableView , NSIndexPath indexPath ) { selected = model . Channels [ indexPath . Row ]. Id ; tableView . ReloadData (); }
private async Task Move () { try { GenericResult result = await model . RunAdminAsync ( client => client . MoveUserAsync ( user . Id , selected )); if (! result . Ok ) throw new InvalidOperationException ( result . Message ); NavigationController ?. PopViewController ( true ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } }
}
internal sealed class BanUserController : UIViewController
{
private readonly AppModel model ; private readonly User user ; private readonly UITextField reason = UiHelpers . Field ( "Reason (optional)" ); private readonly UISegmentedControl duration = new ([ "Permanent" , "1 hour" , "1 day" , "1 week" ]);
internal BanUserController ( AppModel model , User user ) { this . model = model ; this . user = user ; Title = $"Ban {user.Nickname}" ; }
public override void ViewDidLoad () { base . ViewDidLoad (); View !. BackgroundColor = UIColor . SystemGroupedBackground ; duration . SelectedSegment = 0 ; duration . AccessibilityLabel = "Ban duration" ; UIStackView stack = new ([ reason , duration ]) { Axis = UILayoutConstraintAxis . Vertical , Spacing = 16 , TranslatesAutoresizingMaskIntoConstraints = false }; View . AddSubview ( stack ); NSLayoutConstraint . ActivateConstraints ([ stack . TopAnchor . ConstraintEqualTo ( View . SafeAreaLayoutGuide . TopAnchor , 24 ), stack . LeadingAnchor . ConstraintEqualTo ( View . LayoutMarginsGuide . LeadingAnchor ), stack . TrailingAnchor . ConstraintEqualTo ( View . LayoutMarginsGuide . TrailingAnchor )]); NavigationItem . RightBarButtonItem = new ( "Ban" , UIBarButtonItemStyle . Done , async ( _ , _ ) => await Ban ()); }
private async Task Ban () { try { ulong expires = duration . SelectedSegment switch { 1 => ( ulong ) DateTimeOffset . UtcNow . AddHours ( 1 ). ToUnixTimeMilliseconds (), 2 => ( ulong ) DateTimeOffset . UtcNow . AddDays ( 1 ). ToUnixTimeMilliseconds (), 3 => ( ulong ) DateTimeOffset . UtcNow . AddDays ( 7 ). ToUnixTimeMilliseconds (), _ => 0 }; GenericResult result = await model . RunAdminAsync ( client => client . BanUserAsync ( user . Id , reason . Text ?? "" , expires )); if (! result . Ok ) throw new InvalidOperationException ( result . Message ); NavigationController ?. PopViewController ( true ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } }
}
internal sealed class PerUserTuningController : FormController
{
2026-09-22 22:07:00 +02:00
private readonly AppModel model ; private readonly uint userId , streamId ; private readonly UISlider gain = new () { MinValue = 0 , MaxValue = 4 , Value = 1 };
2026-09-19 19:33:10 +02:00
private bool muted , noise ;
2026-09-22 22:07:00 +02:00
private User ? User => model . Users . FirstOrDefault ( value => value . Id == userId );
private StreamInfo ? Stream => User ?. Streams . FirstOrDefault ( value => value . StreamId == streamId );
internal PerUserTuningController ( AppModel model , uint userId , uint streamId ) : base ( "Audio tuning" ) { this . model = model ; this . userId = userId ; this . streamId = streamId ; }
public override void ViewDidLoad () { base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "tuning" ); StreamInfo ? stream = Stream ; Title = stream ?. Label ?? "Audio tuning" ; gain . AccessibilityLabel = $"Volume gain for {User?.Nickname ?? $" User { userId } "}, {stream?.Label ?? " stream "}" ; gain . ValueChanged += ( _ , _ ) => Apply (); if ( model . Client ?. Audio . GetRemotePlayback ( userId , streamId ) is { } state ) { gain . Value = state . Gain ; muted = state . Muted ; noise = state . NoiseReduction ; } }
public override nint RowsInSection ( UITableView tableView , nint section ) => Stream ?. Kind == StreamKind . StreamMic ? 3 : 2 ;
2026-09-19 19:33:10 +02:00
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath indexPath ) { string title = indexPath . Row switch { 0 => "Gain" , 1 => "Mute" , _ => "Noise reduction" }; UITableViewCell cell = TextCell ( tableView , indexPath , "tuning" , title ); if ( indexPath . Row == 0 ) cell . AccessoryView = gain ; else { int row = indexPath . Row ; cell . AccessoryView = Switch ( row == 1 ? muted : noise , title , ( _ , _ ) => { bool value = (( UISwitch ) cell . AccessoryView !). On ; if ( row == 1 ) muted = value ; else noise = value ; Apply (); }); } return cell ; }
2026-09-22 22:07:00 +02:00
private void Apply () { if ( model . Client is not { } client || Stream is null ) return ; client . Audio . SetRemotePlayback ( userId , streamId , gain . Value , muted , noise ); }
2026-09-19 19:33:10 +02:00
}