151 lines
16 KiB
C#
151 lines
16 KiB
C#
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}";
|
|
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;
|
|
}
|
|
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<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
|
|
{
|
|
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); }
|
|
}
|