Lay out Available Mults and Qs like N1MM's window
The window is now a summary table of mults, Qs and totals per band over a sortable spot grid with Call, Freq, Mode, Dir, Mlt, SN, TS, Spotter, Grid, Sun, Pts and a preferred-spotter column. Columns can be hidden, headers sorted, and the list stops re-sorting for six seconds under the mouse. Adds the Mults / Mults & Qs button, a Bands & Modes filter, delete and blacklist from the right-click menu, Alt+A stepping, and QSY to the top spot after logging. Spot gains Snr and IsSkimmer, Bandmap gains Remaining, and AvailableStation carries the path to the station. The menu item no longer has apostrophes. SO2R, SO2V and the other missing pieces are listed in docs/unfinished.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,28 @@ public sealed record Settings
|
||||
|
||||
public int AvailableFontSize { get; init; } = 13;
|
||||
|
||||
/// The Available Mults and Qs window lists only the stations that bring a
|
||||
/// multiplier. Off lists the stations that are only points as well.
|
||||
public bool AvailableMultsOnly { get; init; }
|
||||
|
||||
/// Headers of the columns the window hides. Call cannot be hidden.
|
||||
public IReadOnlyList<string> AvailableHiddenColumns { get; init; } = [];
|
||||
|
||||
/// Band names the window is limited to. Empty is every band.
|
||||
public IReadOnlyList<string> AvailableBands { get; init; } = [];
|
||||
|
||||
/// `CW`, `PHONE` or `DIGITAL`. Empty is every mode.
|
||||
public IReadOnlyList<string> AvailableModes { get; init; } = [];
|
||||
|
||||
/// Band names in the summary table. Empty is the contest's bands.
|
||||
public IReadOnlyList<string> AvailableSummaryBands { get; init; } = [];
|
||||
|
||||
public bool AvailableShowsSummary { get; init; } = true;
|
||||
|
||||
/// After a contact is logged while searching and pounce, the radio goes to
|
||||
/// the newest spot. Needs the list sorted on TS, newest first.
|
||||
public bool AvailableQsyAfterLogging { get; init; }
|
||||
|
||||
/// Moves each incoming CW spot by a few tens of hertz, so the operator has
|
||||
/// to find the station by ear. N1MM calls it randomising.
|
||||
public bool RandomizeSpots { get; init; }
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// One line of the available window. The text properties are what the cells
|
||||
/// show; the typed ones are what the columns sort on, so 9 comes before 10.
|
||||
public sealed class AvailableRow
|
||||
{
|
||||
/// A spot this close to dropping off the bandmap gets a clock beside its
|
||||
/// time.
|
||||
private static readonly TimeSpan ExpiringWithin = TimeSpan.FromMinutes(3);
|
||||
|
||||
/// The sun marker shows from this long before sunrise or sunset until this
|
||||
/// long after.
|
||||
private static readonly TimeSpan SunWindow = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly DateTime nowUtc;
|
||||
|
||||
public AvailableRow(
|
||||
AvailableStation station,
|
||||
string mode,
|
||||
DateTime nowUtc,
|
||||
TimeSpan? remaining,
|
||||
bool isPreferred,
|
||||
bool isTuned)
|
||||
{
|
||||
Station = station;
|
||||
Mode = mode;
|
||||
this.nowUtc = nowUtc;
|
||||
Remaining = remaining;
|
||||
IsPreferred = isPreferred;
|
||||
IsTuned = isTuned;
|
||||
SunMinutes = SunMinutesFor(station, nowUtc);
|
||||
}
|
||||
|
||||
public AvailableStation Station { get; }
|
||||
|
||||
public TimeSpan? Remaining { get; }
|
||||
|
||||
public bool IsPreferred { get; }
|
||||
|
||||
/// The radio is on this spot, which the window paints in reverse.
|
||||
public bool IsTuned { get; }
|
||||
|
||||
public string Call => Station.Spot.Call.Text;
|
||||
|
||||
public double Kilohertz => Station.Spot.Frequency.Kilohertz;
|
||||
|
||||
public string Frequency => Kilohertz.ToString("0.0", CultureInfo.InvariantCulture) + (Station.Spot.IsSplit ? " »" : "");
|
||||
|
||||
public string Mode { get; }
|
||||
|
||||
public int? Heading => Station.Heading is { } heading ? (int)Math.Round(heading) % 360 : null;
|
||||
|
||||
public string Direction => Heading?.ToString(CultureInfo.InvariantCulture) ?? "";
|
||||
|
||||
public int Multipliers => Station.NewMultipliers.Count;
|
||||
|
||||
public int? Snr => Station.Spot.Snr;
|
||||
|
||||
public string Noise => Snr?.ToString(CultureInfo.InvariantCulture) ?? "";
|
||||
|
||||
public DateTime AtUtc => Station.Spot.AtUtc;
|
||||
|
||||
public bool IsExpiring => Remaining is { } left && left <= ExpiringWithin;
|
||||
|
||||
public string Time => AtUtc.ToString("HH:mm", CultureInfo.InvariantCulture) + (IsExpiring ? " ◴" : "");
|
||||
|
||||
public string Spotter => Station.Spot.Spotter + (Station.Spot.IsSkimmer ? " (#)" : "");
|
||||
|
||||
public string Grid => Station.Grid;
|
||||
|
||||
/// Minutes to the next sunrise or sunset at the station, negative once it
|
||||
/// has passed, or `int.MaxValue` when the sun is not within the hour.
|
||||
public int SunMinutes { get; }
|
||||
|
||||
public string Sun => SunMinutes == int.MaxValue ? "" : SunText;
|
||||
|
||||
public int Points => Station.Verdict.Points;
|
||||
|
||||
public string Star => IsPreferred ? "★" : "";
|
||||
|
||||
public IBrush Colour => Verdicts.Colour(Station.Verdict);
|
||||
|
||||
public IBrush Background => Verdicts.Background(Station.Verdict);
|
||||
|
||||
/// What a refresh compares to see whether the list changed.
|
||||
public string Key =>
|
||||
$"{Call}|{Station.Band.Name}|{Frequency}|{Multipliers}|{Points}|{Spotter}|{Time}|{Sun}|{Star}|{IsTuned}";
|
||||
|
||||
public string Tip
|
||||
{
|
||||
get
|
||||
{
|
||||
List<string> lines = [$"{Call} spotted by {Station.Spot.Spotter}", $"{(int)(nowUtc - AtUtc).TotalMinutes} min ago"];
|
||||
if (Station.Spot.Comment.Length > 0)
|
||||
{
|
||||
lines.Add(Station.Spot.Comment);
|
||||
}
|
||||
if (Station.Spot.IsSplit)
|
||||
{
|
||||
lines.Add($"listening on {Station.Spot.Qsx.Kilohertz:0.0}");
|
||||
}
|
||||
if (Sun.Length > 0)
|
||||
{
|
||||
lines.Add(SunNote);
|
||||
}
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
}
|
||||
|
||||
private string SunText
|
||||
{
|
||||
get
|
||||
{
|
||||
(bool isRise, int minutes) = Nearest(Station, nowUtc)!.Value;
|
||||
return $"{(isRise ? "▲" : "▼")}{minutes:+0;-0;0}";
|
||||
}
|
||||
}
|
||||
|
||||
private string SunNote
|
||||
{
|
||||
get
|
||||
{
|
||||
(bool isRise, int minutes) = Nearest(Station, nowUtc)!.Value;
|
||||
string when = minutes >= 0 ? $"in {minutes} min" : $"{-minutes} min ago";
|
||||
return $"{(isRise ? "sunrise" : "sunset")} at the station {when}";
|
||||
}
|
||||
}
|
||||
|
||||
private static int SunMinutesFor(AvailableStation station, DateTime nowUtc) =>
|
||||
Nearest(station, nowUtc) is { } near ? near.Minutes : int.MaxValue;
|
||||
|
||||
/// The sunrise or sunset within the hour either side, whichever is nearer.
|
||||
private static (bool IsRise, int Minutes)? Nearest(AvailableStation station, DateTime nowUtc)
|
||||
{
|
||||
(bool IsRise, int Minutes)? best = null;
|
||||
foreach ((bool isRise, DateTime? at) in new[] { (true, station.Path?.Sunrise), (false, station.Path?.Sunset) })
|
||||
{
|
||||
if (at is not { } time || (time - nowUtc).Duration() > SunWindow)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int minutes = (int)Math.Round((time - nowUtc).TotalMinutes);
|
||||
if (best is null || Math.Abs(minutes) < Math.Abs(best.Value.Minutes))
|
||||
{
|
||||
best = (isRise, minutes);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,37 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
xmlns:theming="using:Nonemm.App.Theming"
|
||||
x:Class="Nonemm.App.Windows.AvailableWindow"
|
||||
Title="Available mults and Qs" Width="620" Height="340">
|
||||
Title="Available Mults and Qs" Width="780" Height="440">
|
||||
<DockPanel Margin="6">
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="0,0,0,6">
|
||||
<CheckBox Name="MultsOnlyBox" Content="Mults only" />
|
||||
<Button Name="ModeButton" MinWidth="90" ToolTip.Tip="Show only multipliers, or multipliers and Qs" />
|
||||
<Button Name="FilterButton" Content="Bands & Modes" />
|
||||
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" VerticalAlignment="Center" />
|
||||
<TextBlock Name="NoticeText" FontSize="11" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<ScrollViewer>
|
||||
<Grid Name="Columns" />
|
||||
</ScrollViewer>
|
||||
<Grid Name="Summary" DockPanel.Dock="Top" Margin="0,0,0,6" HorizontalAlignment="Left" />
|
||||
<DataGrid Name="Spots" IsReadOnly="True" GridLinesVisibility="Horizontal" CanUserSortColumns="True"
|
||||
CanUserReorderColumns="True" CanUserResizeColumns="True" SelectionMode="Single"
|
||||
FontFamily="{x:Static theming:Fonts.Mono}">
|
||||
<DataGrid.Styles>
|
||||
<Style Selector="DataGridCell TextBlock">
|
||||
<Setter Property="FontFamily" Value="{x:Static theming:Fonts.Mono}" />
|
||||
<Setter Property="FontSize" Value="{DynamicResource AvailableFontSize}" />
|
||||
<Setter Property="Margin" Value="6,0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridCell">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="0,1" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
</DataGrid.Styles>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
|
||||
@@ -1,117 +1,686 @@
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.App.Theming;
|
||||
using System.ComponentModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// What is left to work, a column per band: the multipliers at the top of each
|
||||
/// column, then the stations that are only points. Clicking one puts the radio
|
||||
/// there with the call in the entry window, the same as clicking a spot on the
|
||||
/// bandmap.
|
||||
/// The spotted stations that are not worked yet, as N1MM lays them out: a
|
||||
/// summary of how many multipliers and Qs each band holds, and under it one
|
||||
/// line per station that can be sorted on any column. Clicking a line puts the
|
||||
/// radio there with the call in the entry window, the same as clicking a spot
|
||||
/// on the bandmap.
|
||||
public sealed partial class AvailableWindow : RefreshableWindow
|
||||
{
|
||||
private const string HelpUrl = "https://n1mmwp.hamdocs.com/manual-windows/available-mults-and-qs-window/";
|
||||
|
||||
/// The list stops re-sorting for this long after the mouse touches it, so
|
||||
/// the line being aimed at does not move.
|
||||
private static readonly TimeSpan PauseFor = TimeSpan.FromSeconds(6);
|
||||
|
||||
/// The list is rebuilt this often with nothing else to cause it, so the sun
|
||||
/// times and the expiry clocks stay right.
|
||||
private const int RefreshEveryTicks = 15;
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly Action<Frequency, string> tune;
|
||||
private readonly DispatcherTimer timer = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||
|
||||
/// The bands a click in the summary table limits the list to. Empty is
|
||||
/// every band.
|
||||
private readonly HashSet<Band> clicked = [];
|
||||
|
||||
private AvailableRow? menuRow;
|
||||
private bool menuOnHeader;
|
||||
private string shownKey = "";
|
||||
private DateTime pausedUntil = DateTime.MinValue;
|
||||
private bool refreshPending;
|
||||
private int ticks;
|
||||
|
||||
public AvailableWindow(AppSession session, Action<Frequency, string> tune)
|
||||
{
|
||||
this.session = session;
|
||||
this.tune = tune;
|
||||
InitializeComponent();
|
||||
BuildColumns();
|
||||
ApplyColumns();
|
||||
ApplyFontSize();
|
||||
_ = new FontZoom(this, new Settings().AvailableFontSize, () => session.Settings.AvailableFontSize, size =>
|
||||
{
|
||||
session.Save(session.Settings with { AvailableFontSize = size });
|
||||
ApplyFontSize();
|
||||
Refresh();
|
||||
});
|
||||
MultsOnlyBox.IsCheckedChanged += (_, _) => Refresh();
|
||||
ModeButton.Click += (_, _) => SetMultsOnly(!session.Settings.AvailableMultsOnly);
|
||||
FilterButton.Click += (_, _) => OpenFilterMenu();
|
||||
Spots.AddHandler(PointerPressedEvent, OnSpotPressed, RoutingStrategies.Tunnel);
|
||||
Spots.PointerMoved += (_, _) => Pause();
|
||||
Spots.PointerWheelChanged += (_, _) => Pause();
|
||||
Spots.PointerExited += (_, _) => Resume();
|
||||
Spots.LoadingRow += (_, e) => Paint(e.Row);
|
||||
Spots.ContextMenu = BuildMenu();
|
||||
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
||||
timer.Tick += (_, _) => OnTick();
|
||||
timer.Start();
|
||||
Closed += (_, _) => timer.Stop();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void ApplyFontSize()
|
||||
{
|
||||
Resources["AvailableFontSize"] = (double)session.Settings.AvailableFontSize;
|
||||
Spots.FontSize = session.Settings.AvailableFontSize;
|
||||
}
|
||||
|
||||
// ---- paused sorting
|
||||
|
||||
private bool IsPaused => DateTime.UtcNow < pausedUntil;
|
||||
|
||||
private void Pause()
|
||||
{
|
||||
pausedUntil = DateTime.UtcNow + PauseFor;
|
||||
ShowNotice();
|
||||
}
|
||||
|
||||
private void Resume()
|
||||
{
|
||||
pausedUntil = DateTime.MinValue;
|
||||
ShowNotice();
|
||||
if (refreshPending)
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowNotice() =>
|
||||
NoticeText.Text = IsPaused
|
||||
? $"Scrolling Pausing {Math.Max(1, (int)Math.Ceiling((pausedUntil - DateTime.UtcNow).TotalSeconds))} secs"
|
||||
: "";
|
||||
|
||||
private void OnTick()
|
||||
{
|
||||
ShowNotice();
|
||||
if (IsPaused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (refreshPending || ++ticks >= RefreshEveryTicks)
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- columns
|
||||
|
||||
private void BuildColumns()
|
||||
{
|
||||
Column("Call", nameof(AvailableRow.Call), r => r.Call, 110);
|
||||
Column("Freq", nameof(AvailableRow.Frequency), r => r.Kilohertz, 90);
|
||||
Column("Mode", nameof(AvailableRow.Mode), r => r.Mode, 55);
|
||||
Column("Dir", nameof(AvailableRow.Direction), r => r.Heading ?? -1, 50);
|
||||
Column("Mlt", nameof(AvailableRow.Multipliers), r => r.Multipliers, 50);
|
||||
Column("SN", nameof(AvailableRow.Noise), r => r.Snr ?? int.MinValue, 45);
|
||||
Column("TS", nameof(AvailableRow.Time), r => r.AtUtc, 75);
|
||||
Column("Spotter", nameof(AvailableRow.Spotter), r => r.Spotter, 110);
|
||||
Column("Grid", nameof(AvailableRow.Grid), r => r.Grid, 65);
|
||||
Column("Sun", nameof(AvailableRow.Sun), r => r.SunMinutes, 60);
|
||||
Column("Pts", nameof(AvailableRow.Points), r => r.Points, 50);
|
||||
Column("★", nameof(AvailableRow.Star), r => r.IsPreferred ? 1 : 0, 35);
|
||||
}
|
||||
|
||||
/// The grid sorts on the typed value behind the cell, not on its text.
|
||||
private void Column<T>(string header, string path, Func<AvailableRow, T> key, double width)
|
||||
where T : IComparable<T>
|
||||
{
|
||||
Spots.Columns.Add(new DataGridTextColumn
|
||||
{
|
||||
Header = header,
|
||||
Width = new DataGridLength(width),
|
||||
Binding = new Binding(path) { Mode = BindingMode.OneWay },
|
||||
CustomSortComparer = Comparer<AvailableRow>.Create((left, right) => key(left).CompareTo(key(right))),
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyColumns()
|
||||
{
|
||||
foreach (DataGridColumn column in Spots.Columns)
|
||||
{
|
||||
column.IsVisible = Header(column) == "Call" || !session.Settings.AvailableHiddenColumns.Contains(Header(column));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Header(DataGridColumn column) => (string)column.Header!;
|
||||
|
||||
private void SetColumnShown(string header, bool shown)
|
||||
{
|
||||
List<string> hidden = [.. session.Settings.AvailableHiddenColumns.Where(h => h != header)];
|
||||
if (!shown)
|
||||
{
|
||||
hidden.Add(header);
|
||||
}
|
||||
session.Save(session.Settings with { AvailableHiddenColumns = hidden });
|
||||
ApplyColumns();
|
||||
}
|
||||
|
||||
private void AutoResizeColumns()
|
||||
{
|
||||
foreach (DataGridColumn column in Spots.Columns)
|
||||
{
|
||||
column.Width = DataGridLength.SizeToCells;
|
||||
}
|
||||
}
|
||||
|
||||
/// The list is sorted newest first on TS, which is the only order the
|
||||
/// QSY-after-logging option works in.
|
||||
private bool IsNewestFirst =>
|
||||
Spots.ItemsSource is DataGridCollectionView { SortDescriptions: [{ } first, ..] }
|
||||
&& first is DataGridComparerSortDescription comparer
|
||||
&& comparer.SourceComparer == Spots.Columns.First(c => Header(c) == "TS").CustomSortComparer
|
||||
&& first.Direction == ListSortDirection.Descending;
|
||||
|
||||
// ---- refresh
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
Columns.Children.Clear();
|
||||
Columns.ColumnDefinitions.Clear();
|
||||
refreshPending = false;
|
||||
ticks = 0;
|
||||
Summary.Children.Clear();
|
||||
Summary.RowDefinitions.Clear();
|
||||
Summary.ColumnDefinitions.Clear();
|
||||
Summary.IsVisible = session.Settings.AvailableShowsSummary;
|
||||
bool multsOnly = session.Settings.AvailableMultsOnly;
|
||||
ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs";
|
||||
ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
|
||||
if (session.Available is null)
|
||||
{
|
||||
StatusText.Text = "no contest is open";
|
||||
Spots.ItemsSource = Array.Empty<AvailableRow>();
|
||||
shownKey = "";
|
||||
return;
|
||||
}
|
||||
|
||||
session.Bandmap.DropOlderThan(DateTime.UtcNow);
|
||||
bool multsOnly = MultsOnlyBox.IsChecked == true;
|
||||
List<AvailableStation> stations = session.Available.All()
|
||||
.Where(s => !multsOnly || s.IsMultiplier)
|
||||
.ToList();
|
||||
List<Band> bands = stations
|
||||
.Select(s => s.Band)
|
||||
.Distinct()
|
||||
.OrderBy(b => b.Low.Hertz)
|
||||
.ToList();
|
||||
|
||||
int at = 0;
|
||||
foreach (Band band in bands)
|
||||
DateTime now = DateTime.UtcNow;
|
||||
session.Bandmap.DropOlderThan(now);
|
||||
IReadOnlyList<AvailableStation> all = session.Available.All();
|
||||
if (Summary.IsVisible)
|
||||
{
|
||||
Columns.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
|
||||
Control column = BuildColumn(band, stations.Where(s => s.Band == band).ToList());
|
||||
Grid.SetColumn(column, at++);
|
||||
Columns.Children.Add(column);
|
||||
BuildSummary(all);
|
||||
}
|
||||
int mults = all.Count(s => s.IsMultiplier);
|
||||
Title = $"Available Mults and Qs — {all.Count - mults} Qs · {mults} Mults · {all.Count} total";
|
||||
StatusText.Text = all.Count == 0
|
||||
? "No workable spots: check the contest, the bands and the cluster filters"
|
||||
: $"{mults} mults · {all.Count - mults} Qs · {all.Count} total";
|
||||
if (IsPaused)
|
||||
{
|
||||
refreshPending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
int mults = stations.Count(s => s.IsMultiplier);
|
||||
StatusText.Text = stations.Count == 0
|
||||
? "nothing spotted that is not worked"
|
||||
: $"{stations.Count} to work · {mults} multipliers";
|
||||
IReadOnlyList<string> preferred = session.Settings.SpotFilter.PreferredSpotters;
|
||||
Frequency? tuned = session.Position?.Frequency;
|
||||
List<AvailableRow> rows = all
|
||||
.Where(s => (!multsOnly || s.IsMultiplier) && IsShown(s))
|
||||
.Select(s => new AvailableRow(
|
||||
s,
|
||||
ModeName(s.Spot.Frequency),
|
||||
now,
|
||||
session.Bandmap.Remaining(s.Spot),
|
||||
preferred.Contains(s.Spot.Spotter, StringComparer.OrdinalIgnoreCase),
|
||||
tuned is { } at && Math.Abs(at.Hertz - s.Spot.Frequency.Hertz) <= 100))
|
||||
.ToList();
|
||||
Show(rows);
|
||||
}
|
||||
|
||||
private Control BuildColumn(Band band, IReadOnlyList<AvailableStation> stations)
|
||||
private bool IsShown(AvailableStation station)
|
||||
{
|
||||
StackPanel panel = new() { Margin = new Avalonia.Thickness(0, 0, 8, 0) };
|
||||
panel.Children.Add(new TextBlock
|
||||
IReadOnlyList<string> bands = session.Settings.AvailableBands;
|
||||
IReadOnlyList<string> modes = session.Settings.AvailableModes;
|
||||
ModeCategory? category = session.BandPlan.ModeAt(station.Spot.Frequency);
|
||||
return (clicked.Count == 0 || clicked.Contains(station.Band))
|
||||
&& (bands.Count == 0 || bands.Contains(station.Band.Name, StringComparer.OrdinalIgnoreCase))
|
||||
&& (modes.Count == 0 || category is null || modes.Contains(ModeKey(category.Value), StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string ModeKey(ModeCategory category) => category switch
|
||||
{
|
||||
ModeCategory.Phone => "PHONE",
|
||||
ModeCategory.Digital => "DIGITAL",
|
||||
_ => "CW",
|
||||
};
|
||||
|
||||
/// A refresh that changes nothing leaves the list alone, so the scroll
|
||||
/// position and the picked line stay where the operator left them.
|
||||
private void Show(List<AvailableRow> rows)
|
||||
{
|
||||
string key = string.Join(';', rows.Select(r => r.Key));
|
||||
if (key == shownKey && Spots.ItemsSource is not null)
|
||||
{
|
||||
Text = $"{band.Name} {stations.Count}",
|
||||
FontSize = session.Settings.AvailableFontSize - 2,
|
||||
Opacity = 0.7,
|
||||
Margin = new Avalonia.Thickness(0, 0, 0, 4),
|
||||
});
|
||||
foreach (AvailableStation station in stations)
|
||||
{
|
||||
panel.Children.Add(Row(station));
|
||||
return;
|
||||
}
|
||||
shownKey = key;
|
||||
DataGridCollectionView view = new(rows);
|
||||
if (Spots.ItemsSource is DataGridCollectionView old)
|
||||
{
|
||||
foreach (DataGridSortDescription sort in old.SortDescriptions)
|
||||
{
|
||||
view.SortDescriptions.Add(sort);
|
||||
}
|
||||
}
|
||||
string? picked = (Spots.SelectedItem as AvailableRow)?.Call;
|
||||
Spots.ItemsSource = view;
|
||||
if (picked is not null && rows.FirstOrDefault(r => r.Call == picked) is { } again)
|
||||
{
|
||||
Spots.SelectedItem = again;
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
private Control Row(AvailableStation station)
|
||||
private string ModeName(Frequency frequency) => session.BandPlan.ModeAt(frequency) switch
|
||||
{
|
||||
TextBlock text = new()
|
||||
ModeCategory.Cw => "CW",
|
||||
ModeCategory.Phone => "SSB",
|
||||
ModeCategory.Digital => "DIG",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
/// A line is painted in the colour of what it would bring. The one the
|
||||
/// radio is on is painted in reverse.
|
||||
private static void Paint(DataGridRow line)
|
||||
{
|
||||
if (line.DataContext is not AvailableRow row)
|
||||
{
|
||||
Text = Label(station),
|
||||
FontFamily = Fonts.Mono,
|
||||
return;
|
||||
}
|
||||
line.Foreground = row.IsTuned ? row.Background : row.Colour;
|
||||
line.Background = row.IsTuned ? row.Colour : Brushes.Transparent;
|
||||
ToolTip.SetTip(line, row.Tip);
|
||||
}
|
||||
|
||||
// ---- summary table
|
||||
|
||||
private IReadOnlyList<Band> SummaryBands(IReadOnlyList<AvailableStation> all)
|
||||
{
|
||||
IReadOnlyList<string> chosen = session.Settings.AvailableSummaryBands;
|
||||
IEnumerable<Band> basis = chosen.Count > 0
|
||||
? chosen.Select(Bands.Named).OfType<Band>()
|
||||
: Bands.Contest;
|
||||
return [.. basis.Concat(all.Select(s => s.Band)).Distinct().OrderBy(b => b.Low.Hertz)];
|
||||
}
|
||||
|
||||
private void BuildSummary(IReadOnlyList<AvailableStation> all)
|
||||
{
|
||||
IReadOnlyList<Band> columns = SummaryBands(all);
|
||||
Dictionary<Band, int> mults = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && s.IsMultiplier));
|
||||
Dictionary<Band, int> qs = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && !s.IsMultiplier));
|
||||
int mostMults = mults.Values.Max();
|
||||
int mostQs = qs.Values.Max();
|
||||
|
||||
for (int row = 0; row < 4; row++)
|
||||
{
|
||||
Summary.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
}
|
||||
Summary.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
|
||||
foreach (Band _ in columns)
|
||||
{
|
||||
Summary.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
|
||||
}
|
||||
|
||||
Label(0, 0, "", null);
|
||||
Label(1, 0, "Mults", e => ShowBands([], null, e));
|
||||
Label(2, 0, "Qs", e => ShowBands([], null, e));
|
||||
Label(3, 0, "Total", e => ShowBands([], null, e));
|
||||
for (int at = 0; at < columns.Count; at++)
|
||||
{
|
||||
Band band = columns[at];
|
||||
int column = at + 1;
|
||||
Label(0, column, band.Name.TrimEnd('M', 'm'), e => ShowBands([band], null, e), clicked.Contains(band));
|
||||
Label(1, column, Count(mults[band]), e => ShowBands([band], true, e), mults[band] > 0 && mults[band] == mostMults);
|
||||
Label(2, column, Count(qs[band]), e => ShowBands([band], false, e), qs[band] > 0 && qs[band] == mostQs);
|
||||
Label(3, column, Count(mults[band] + qs[band]), e => ShowBands([band], null, e));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Count(int count) => count == 0 ? "·" : count.ToString();
|
||||
|
||||
private void Label(int row, int column, string text, Action<PointerPressedEventArgs>? click, bool highlight = false)
|
||||
{
|
||||
TextBlock block = new()
|
||||
{
|
||||
Text = text,
|
||||
FontFamily = Theming.Fonts.Mono,
|
||||
FontSize = session.Settings.AvailableFontSize,
|
||||
Foreground = Verdicts.Colour(station.Verdict),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Cursor = new Cursor(StandardCursorType.Hand),
|
||||
Margin = new Thickness(6, 0),
|
||||
HorizontalAlignment = column == 0 ? HorizontalAlignment.Left : HorizontalAlignment.Right,
|
||||
Foreground = highlight ? Verdicts.NewMultiplier : null,
|
||||
FontWeight = highlight ? FontWeight.Bold : FontWeight.Normal,
|
||||
};
|
||||
ToolTip.SetTip(text, Tip(station));
|
||||
text.PointerPressed += (_, _) => tune(station.Spot.Frequency, station.Spot.Call.Text);
|
||||
return text;
|
||||
if (click is not null)
|
||||
{
|
||||
block.Cursor = new Cursor(StandardCursorType.Hand);
|
||||
block.PointerPressed += (_, e) => click(e);
|
||||
}
|
||||
Grid.SetRow(block, row);
|
||||
Grid.SetColumn(block, column);
|
||||
Summary.Children.Add(block);
|
||||
}
|
||||
|
||||
private static string Label(AvailableStation station)
|
||||
/// A click on a band shows that band alone and shift adds it to the ones
|
||||
/// shown. `multsOnly` is null when the click does not say. No bands is all
|
||||
/// of them.
|
||||
private void ShowBands(IReadOnlyList<Band> picked, bool? multsOnly, PointerPressedEventArgs e)
|
||||
{
|
||||
string call = $"{station.Spot.Frequency.Kilohertz,9:0.0} {station.Spot.Call.Text}";
|
||||
return station.IsMultiplier ? $"{call} · {station.MultiplierText}" : call;
|
||||
if (!e.KeyModifiers.HasFlag(KeyModifiers.Shift) || picked.Count == 0)
|
||||
{
|
||||
clicked.Clear();
|
||||
}
|
||||
foreach (Band band in picked)
|
||||
{
|
||||
clicked.Add(band);
|
||||
}
|
||||
pausedUntil = DateTime.MinValue;
|
||||
if (multsOnly is { } only && only != session.Settings.AvailableMultsOnly)
|
||||
{
|
||||
session.Save(session.Settings with { AvailableMultsOnly = only });
|
||||
}
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private static string Tip(AvailableStation station)
|
||||
private void SetMultsOnly(bool multsOnly)
|
||||
{
|
||||
string who = station.Spot.Spotter.Length > 0 ? $" de {station.Spot.Spotter}" : "";
|
||||
return $"{Verdicts.Describe(station.Verdict)}{who}";
|
||||
pausedUntil = DateTime.MinValue;
|
||||
session.Save(session.Settings with { AvailableMultsOnly = multsOnly });
|
||||
Refresh();
|
||||
}
|
||||
|
||||
// ---- clicks and keys
|
||||
|
||||
private void OnSpotPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
Visual? source = e.Source as Visual;
|
||||
menuOnHeader = source?.GetSelfAndVisualAncestors().OfType<DataGridColumnHeader>().Any() == true;
|
||||
menuRow = source?.GetSelfAndVisualAncestors().OfType<DataGridRow>().FirstOrDefault()?.DataContext as AvailableRow;
|
||||
Pause();
|
||||
if (menuRow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
PointerPointProperties point = e.GetCurrentPoint(Spots).Properties;
|
||||
if (point.IsLeftButtonPressed)
|
||||
{
|
||||
Pick(menuRow);
|
||||
}
|
||||
else if (point.IsMiddleButtonPressed)
|
||||
{
|
||||
Delete(menuRow);
|
||||
}
|
||||
}
|
||||
|
||||
private void Pick(AvailableRow row)
|
||||
{
|
||||
Spots.SelectedItem = row;
|
||||
tune(row.Station.Spot.Frequency, row.Call);
|
||||
}
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
if (e.Key == Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Alt))
|
||||
{
|
||||
e.Handled = true;
|
||||
Step(e.KeyModifiers);
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt+A moves down the list and QSYs, Shift+Alt+A moves up and Ctrl+Alt+A
|
||||
/// goes to the top. The list wraps. With no line picked nothing happens.
|
||||
public void Step(KeyModifiers modifiers)
|
||||
{
|
||||
if (Spots.SelectedItem is not AvailableRow current || Spots.ItemsSource is not DataGridCollectionView view)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<AvailableRow> rows = [.. view.Cast<AvailableRow>()];
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int at = rows.FindIndex(r => r.Call == current.Call);
|
||||
int next = modifiers.HasFlag(KeyModifiers.Control) ? 0
|
||||
: modifiers.HasFlag(KeyModifiers.Shift) ? (at - 1 + rows.Count) % rows.Count
|
||||
: (at + 1) % rows.Count;
|
||||
Spots.ScrollIntoView(rows[next], null);
|
||||
Pick(rows[next]);
|
||||
}
|
||||
|
||||
/// Called after a contact is logged. When the option is on, the list is
|
||||
/// sorted newest first and the operator is not running, the radio goes to
|
||||
/// the top spot that is not the station just worked.
|
||||
public void QsyToTopSpot(string loggedCall)
|
||||
{
|
||||
if (!session.Settings.AvailableQsyAfterLogging || !IsNewestFirst)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pausedUntil = DateTime.MinValue;
|
||||
Refresh();
|
||||
if (Spots.ItemsSource is not DataGridCollectionView view)
|
||||
{
|
||||
return;
|
||||
}
|
||||
AvailableRow? top = view.Cast<AvailableRow>().FirstOrDefault(r => r.Call != loggedCall);
|
||||
if (top is not null)
|
||||
{
|
||||
Pick(top);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- menus
|
||||
|
||||
private ContextMenu BuildMenu()
|
||||
{
|
||||
ContextMenu menu = new();
|
||||
menu.Opening += (_, _) => menu.ItemsSource = menuOnHeader ? HeaderItems() : SpotItems();
|
||||
return menu;
|
||||
}
|
||||
|
||||
private List<Control> HeaderItems()
|
||||
{
|
||||
List<Control> items = [];
|
||||
foreach (DataGridColumn column in Spots.Columns.Where(c => Header(c) != "Call"))
|
||||
{
|
||||
string header = Header(column);
|
||||
MenuItem item = new()
|
||||
{
|
||||
Header = header == "★" ? "★ preferred spotter" : header,
|
||||
ToggleType = MenuItemToggleType.CheckBox,
|
||||
IsChecked = column.IsVisible,
|
||||
StaysOpenOnClick = true,
|
||||
};
|
||||
item.Click += (_, _) => SetColumnShown(header, !column.IsVisible);
|
||||
items.Add(item);
|
||||
}
|
||||
items.Add(new Separator());
|
||||
items.Add(Action("Auto Resize Columns", AutoResizeColumns));
|
||||
return items;
|
||||
}
|
||||
|
||||
private List<Control> SpotItems()
|
||||
{
|
||||
AvailableRow? row = menuRow;
|
||||
MenuItem delete = Action("Delete Spot", () => Delete(row!));
|
||||
MenuItem callBlacklist = Action("Blacklist callsign", () => Blacklist(row!, spotter: false));
|
||||
MenuItem spotterBlacklist = Action("Blacklist spotter", () => Blacklist(row!, spotter: true));
|
||||
delete.IsEnabled = callBlacklist.IsEnabled = spotterBlacklist.IsEnabled = row is not null;
|
||||
|
||||
MenuItem qs = Check("Show Qs && Mults", !session.Settings.AvailableMultsOnly, () => SetMultsOnly(!session.Settings.AvailableMultsOnly));
|
||||
MenuItem qsyAfter = Check(
|
||||
"QSY to top spot after logging QSO",
|
||||
session.Settings.AvailableQsyAfterLogging,
|
||||
() => session.Save(session.Settings with { AvailableQsyAfterLogging = !session.Settings.AvailableQsyAfterLogging }));
|
||||
qsyAfter.IsEnabled = IsNewestFirst;
|
||||
MenuItem summaryShown = Check(
|
||||
"Show Summary Panel",
|
||||
session.Settings.AvailableShowsSummary,
|
||||
() =>
|
||||
{
|
||||
session.Save(session.Settings with { AvailableShowsSummary = !session.Settings.AvailableShowsSummary });
|
||||
Refresh();
|
||||
});
|
||||
return
|
||||
[
|
||||
delete,
|
||||
callBlacklist,
|
||||
spotterBlacklist,
|
||||
new Separator(),
|
||||
Action("Set Band && Mode Filter Options", OpenFilterMenu),
|
||||
qs,
|
||||
qsyAfter,
|
||||
new Separator(),
|
||||
SummaryBandMenu(),
|
||||
Action("Auto Resize Columns", AutoResizeColumns),
|
||||
summaryShown,
|
||||
new Separator(),
|
||||
Action("Help", OpenHelp),
|
||||
];
|
||||
}
|
||||
|
||||
private static MenuItem Action(string header, Action run)
|
||||
{
|
||||
MenuItem item = new() { Header = header };
|
||||
item.Click += (_, _) => run();
|
||||
return item;
|
||||
}
|
||||
|
||||
private static MenuItem Check(string header, bool isChecked, Action run)
|
||||
{
|
||||
MenuItem item = new() { Header = header, ToggleType = MenuItemToggleType.CheckBox, IsChecked = isChecked };
|
||||
item.Click += (_, _) => run();
|
||||
return item;
|
||||
}
|
||||
|
||||
/// Any mix of bands for the summary table, from the contest's own to the
|
||||
/// microwave bands.
|
||||
private MenuItem SummaryBandMenu()
|
||||
{
|
||||
IReadOnlyList<Band> shown = SummaryBands(session.Available?.All() ?? []);
|
||||
List<Control> items = [];
|
||||
foreach (Band band in Bands.All)
|
||||
{
|
||||
MenuItem item = new()
|
||||
{
|
||||
Header = band.Name,
|
||||
ToggleType = MenuItemToggleType.CheckBox,
|
||||
IsChecked = shown.Contains(band),
|
||||
StaysOpenOnClick = true,
|
||||
};
|
||||
item.Click += (_, _) =>
|
||||
{
|
||||
session.Save(session.Settings with
|
||||
{
|
||||
AvailableSummaryBands =
|
||||
[
|
||||
.. shown.Where(b => b != band).Concat(shown.Contains(band) ? [] : [band]).Select(b => b.Name),
|
||||
],
|
||||
});
|
||||
Refresh();
|
||||
};
|
||||
items.Add(item);
|
||||
}
|
||||
return new MenuItem { Header = "Summary Panel Band Selection", ItemsSource = items };
|
||||
}
|
||||
|
||||
/// The bands and modes the list is limited to. It does not touch the
|
||||
/// telnet window's filters.
|
||||
private void OpenFilterMenu()
|
||||
{
|
||||
List<Control> items = [];
|
||||
foreach (Band band in Bands.ContestWithWarc)
|
||||
{
|
||||
items.Add(FilterItem(band.Name, session.Settings.AvailableBands.Contains(band.Name), on =>
|
||||
session.Settings with { AvailableBands = Toggled(session.Settings.AvailableBands, band.Name, on) }));
|
||||
}
|
||||
items.Add(new Separator());
|
||||
foreach ((string label, string key) in new[] { ("CW", "CW"), ("Phone", "PHONE"), ("Digital", "DIGITAL") })
|
||||
{
|
||||
items.Add(FilterItem(label, session.Settings.AvailableModes.Contains(key), on =>
|
||||
session.Settings with { AvailableModes = Toggled(session.Settings.AvailableModes, key, on) }));
|
||||
}
|
||||
items.Add(new Separator());
|
||||
items.Add(Action("All bands and modes", () =>
|
||||
{
|
||||
session.Save(session.Settings with { AvailableBands = [], AvailableModes = [] });
|
||||
Refresh();
|
||||
}));
|
||||
new ContextMenu { ItemsSource = items }.Open(FilterButton);
|
||||
}
|
||||
|
||||
private MenuItem FilterItem(string header, bool isChecked, Func<bool, Settings> change)
|
||||
{
|
||||
MenuItem item = new()
|
||||
{
|
||||
Header = header,
|
||||
ToggleType = MenuItemToggleType.CheckBox,
|
||||
IsChecked = isChecked,
|
||||
StaysOpenOnClick = true,
|
||||
};
|
||||
item.Click += (_, _) =>
|
||||
{
|
||||
session.Save(change(item.IsChecked));
|
||||
pausedUntil = DateTime.MinValue;
|
||||
Refresh();
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> Toggled(IReadOnlyList<string> names, string name, bool on) =>
|
||||
[.. names.Where(n => n != name).Concat(on ? [name] : [])];
|
||||
|
||||
private void OpenHelp() => _ = TopLevel.GetTopLevel(this)?.Launcher.LaunchUriAsync(new Uri(HelpUrl));
|
||||
|
||||
// ---- changing the bandmap
|
||||
|
||||
private void Delete(AvailableRow row)
|
||||
{
|
||||
pausedUntil = DateTime.MinValue;
|
||||
session.Bandmap.Remove(row.Station.Spot.Call, row.Station.Band);
|
||||
}
|
||||
|
||||
/// Adds the call, or the spotter, to the telnet window's blocked lists and
|
||||
/// takes what it already put on the map off.
|
||||
private void Blacklist(AvailableRow row, bool spotter)
|
||||
{
|
||||
StoredSpotFilter filter = session.Settings.SpotFilter;
|
||||
string name = spotter ? row.Station.Spot.Spotter : row.Call;
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(session.Settings with
|
||||
{
|
||||
SpotFilter = spotter
|
||||
? filter with { BlockedSpotters = [.. filter.BlockedSpotters, name] }
|
||||
: filter with { BlockedCalls = [.. filter.BlockedCalls, name] },
|
||||
});
|
||||
pausedUntil = DateTime.MinValue;
|
||||
IEnumerable<Spot> doomed = session.Bandmap.All().Where(s =>
|
||||
spotter ? s.Spotter.Equals(name, StringComparison.OrdinalIgnoreCase) : s.Call.Text.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (Spot spot in doomed.ToList())
|
||||
{
|
||||
if (spot.Band is { } band)
|
||||
{
|
||||
session.Bandmap.Remove(spot.Call, band);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
</MenuItem>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Wi_ndow">
|
||||
<MenuItem Header="Available Mult's and Q's" Click="OnShowAvailable" />
|
||||
<MenuItem Header="Available Mults and Qs" Click="OnShowAvailable" />
|
||||
<MenuItem Header="_Bandmap" Click="OnShowBandmap" />
|
||||
<MenuItem Header="Call Stack" Click="OnShowCallStack" />
|
||||
<MenuItem Header="Check" Click="OnShowCheck" />
|
||||
|
||||
@@ -504,6 +504,11 @@ public sealed partial class EntryWindow : Window
|
||||
e.Handled = true;
|
||||
OnQuickEditBack(this, new RoutedEventArgs());
|
||||
break;
|
||||
case Key.A when e.KeyModifiers.HasFlag(KeyModifiers.Alt)
|
||||
&& openWindows.GetValueOrDefault(typeof(AvailableWindow)) is AvailableWindow available:
|
||||
e.Handled = true;
|
||||
available.Step(e.KeyModifiers);
|
||||
break;
|
||||
case Key.A when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
OnQuickEditForward(this, new RoutedEventArgs());
|
||||
@@ -656,10 +661,12 @@ public sealed partial class EntryWindow : Window
|
||||
{
|
||||
return;
|
||||
}
|
||||
string loggedCall;
|
||||
try
|
||||
{
|
||||
Qso logged = Logging.LogContact();
|
||||
Status($"logged {logged.Call} for {logged.Points} points");
|
||||
loggedCall = logged.Call.Text;
|
||||
}
|
||||
catch (Exception e) when (e is InvalidOperationException or IOException)
|
||||
{
|
||||
@@ -669,6 +676,10 @@ public sealed partial class EntryWindow : Window
|
||||
ResetEsm();
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
if (!Logging.IsRunning && openWindows.GetValueOrDefault(typeof(AvailableWindow)) is AvailableWindow list)
|
||||
{
|
||||
list.QsyToTopSpot(loggedCall);
|
||||
}
|
||||
}
|
||||
|
||||
/// N1MM's `{FORCELOG}`: the contact goes in the log with whatever is in the
|
||||
|
||||
Reference in New Issue
Block a user