using UIKit; using VoiceCat.Audio; using Voicecat.V1; using Channel = Voicecat.V1.Channel; namespace VoiceCat.iOS; internal sealed class MainTabController : UITabBarController { internal MainTabController(AppModel model) { 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); ViewControllers = [channels, chat, settings]; } 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 operation) { try { await operation(); } catch (Exception exception) { if (Window?.RootViewController is { } owner) UiHelpers.ShowError(owner, exception); } } } internal sealed class ChannelsController : UITableViewController { 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() { 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(); } private void Reload() { TableView.ReloadData(); NavigationItem.RightBarButtonItems![1].Enabled = model.Client?.Permissions is { } p && (p.IsAdmin || p.CanCreateTempChannel); } public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count; public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { (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; } public override async void RowSelected(UITableView tableView, NSIndexPath indexPath) { 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; } } 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() { base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; log.Editable = false; log.Font = UIFont.PreferredBody; log.AccessibilityLabel = "Chat and activity timeline"; log.TranslatesAutoresizingMaskIntoConstraints = false; NavigationItem.RightBarButtonItem = new("Private Chats", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new PrivateChatsController(model), true)); 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(); } private void Refresh() { IEnumerable<(DateTime Time, string Text)> chat = model.Messages.Where(message => !message.Private).Select(message => (message.Timestamp, $"{message.Sender}: {message.Text}")); 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)); } } 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 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; } }