using UIKit; using Voicecat.V1; using Channel = Voicecat.V1.Channel; namespace VoiceCat.iOS; internal sealed class UsersController : UITableViewController { private readonly AppModel model; private IReadOnlyList 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}"; cell.Accessory = user.Id == model.SelfUserId ? UITableViewCellAccessory.None : UITableViewCellAccessory.DisclosureIndicator; if (user.Id != model.SelfUserId) cell.AccessibilityCustomActions = [ new("Open user details", (Func)(_ => { Open(user.Id); return true; })), new("Private message", (Func)(_ => { NavigationController?.PushViewController(new PrivateConversationController(model, user.Id, user.Nickname), true); return true; })) ]; return cell; } public override void RowSelected(UITableView tableView, NSIndexPath indexPath) { User user = Visible[indexPath.Row]; tableView.DeselectRow(indexPath, true); if (user.Id == model.SelfUserId) return; Open(user.Id); } 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 { get { User? user = User; if (user is null) return []; Permissions permissions = model.Client?.Permissions ?? new(); var result = new List<(string, Action, bool)>(); 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; } } 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(); } 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> 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 { private readonly AppModel model; private readonly uint userId, streamId; private readonly UISlider gain = new() { MinValue = 0, MaxValue = 4, Value = 1 }; private bool muted, noise; 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; 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; } private void Apply() { if (model.Client is not { } client || Stream is null) return; client.Audio.SetRemotePlayback(userId, streamId, gain.Value, muted, noise); } }