From 0b99790eabf4f89b58f9c9712c5752f4ffdfbdf2 Mon Sep 17 00:00:00 2001 From: gx1400 Date: Sat, 17 Oct 2026 11:40:05 -0500 Subject: [PATCH] feat(ui): the Messages panel, with conversations, Send via, retries and unread counts docs/features/F2.4-messaging.md behaviors 1-11, 16, UI side: - a Messages panel beside Details: New message, the conversations with unread counts, the open conversation's messages with their state in words, Cancel and Send again, Send via (Automatic or chosen sources) and retries, and the compose box with its message count; Enter sends; - Send message in a station's details opens its conversation; - unread messages in the status bar, opening the panel; - on a gateway's browser the panel says messages are the operator's until sign-in. --- docs/features/F2.4-messaging.md | 5 +- docs/scope.md | 2 +- src/AprsMap.Core/Messenger.cs | 18 + src/AprsMap.UI/Localization/Strings.resx | 100 ++++ src/AprsMap.UI/Messages/MessagesViewModel.cs | 495 ++++++++++++++++++ src/AprsMap.UI/Shell/ShellModel.cs | 10 +- .../Stations/StationDetailsViewModel.cs | 13 + src/AprsMap.UI/ViewModels/MainViewModel.cs | 32 +- src/AprsMap.UI/Views/DetailsPanel.axaml | 5 + src/AprsMap.UI/Views/MainView.axaml | 16 + src/AprsMap.UI/Views/MessagesPanel.axaml | 213 ++++++++ src/AprsMap.UI/Views/MessagesPanel.axaml.cs | 45 ++ src/AprsMap.UI/Views/PanelTemplate.cs | 1 + tests/AprsMap.UI.Tests/MessagesTests.cs | 172 ++++++ tests/AprsMap.UI.Tests/ShellTests.cs | 6 +- tests/AprsMap.UI.Tests/ShellViewTests.cs | 2 +- 16 files changed, 1127 insertions(+), 8 deletions(-) create mode 100644 src/AprsMap.UI/Messages/MessagesViewModel.cs create mode 100644 src/AprsMap.UI/Views/MessagesPanel.axaml create mode 100644 src/AprsMap.UI/Views/MessagesPanel.axaml.cs create mode 100644 tests/AprsMap.UI.Tests/MessagesTests.cs diff --git a/docs/features/F2.4-messaging.md b/docs/features/F2.4-messaging.md index 723feec9..c33bd74f 100644 --- a/docs/features/F2.4-messaging.md +++ b/docs/features/F2.4-messaging.md @@ -1,7 +1,8 @@ # F2.4 Messaging -Status: draft, for 0.5.0 ([#192](https://github.com/APRS-NET/AprsMap/issues/192)). Built on APRS.NET's -`AprsNet.Messaging` (G10, in `0.4.0-alpha.2`). +Status: built for 0.5.0 ([#192](https://github.com/APRS-NET/AprsMap/issues/192)), on APRS.NET's +`AprsNet.Messaging` (G10, in `0.4.0-alpha.2`). The Messages panel docks beside Details; Send message is in a station's +details; unread messages show in the status bar. ## User story diff --git a/docs/scope.md b/docs/scope.md index 23abef4d..1ed47751 100644 --- a/docs/scope.md +++ b/docs/scope.md @@ -109,7 +109,7 @@ milestone ([0.3.0-alpha.1](https://github.com/APRS-NET/AprsMap/milestone/1), don | F2.1 | Own station ([spec](features/F2.1-own-station.md)): callsign, SSID guidance, symbol, comment, passcode, transmit enable and safety interlocks, Send position now; Disable TX per profile, TX enabled and Test send per source, RF through KISS TNCs | SSID, Passcode, Why Won't APRSIS Transmit | App | G01, G02, G13 | built (0.5.0, [#190](https://github.com/APRS-NET/AprsMap/issues/190)) | | F2.2 | Fixed and SmartBeaconing-style position beacons, status beacons, PHG; **Send via** checkmarks per beacon over the sources that may transmit (F2.1), all ticked by default | PHG | App | G01, G11 | scoped (0.5.0, [#196](https://github.com/APRS-NET/AprsMap/issues/196)) | | F2.3 | Predictive beaconing indicator: show where others last saw you versus where you are (our own name for the feature) | Red Dot | App | G11 | scoped (0.6.0, [#197](https://github.com/APRS-NET/AprsMap/issues/197)) | -| F2.4 | Messaging ([spec](features/F2.4-messaging.md)): chat per correspondent, ack/retry status, reply-ack, rejects, offline queue, selectable retry profiles (default, APRS-IS, none, slow HF modem), optional double-ack on RF; **Send via** per thread: Automatic (RF to a correspondent heard on a TNC recently, APRS-IS otherwise, falling back to APRS-IS when RF retries go unacked), or chosen sources; acks go back on the source the message came in on | APRS Messaging Explained | App | G01, G10 | spec'd (0.5.0, [#192](https://github.com/APRS-NET/AprsMap/issues/192)) | +| F2.4 | Messaging ([spec](features/F2.4-messaging.md)): chat per correspondent, ack/retry status, reply-ack, rejects, offline queue, selectable retry profiles (default, APRS-IS, none, slow HF modem), optional double-ack on RF; **Send via** per thread: Automatic (RF to a correspondent heard on a TNC recently, APRS-IS otherwise, falling back to APRS-IS when RF retries go unacked), or chosen sources; acks go back on the source the message came in on | APRS Messaging Explained | App | G01, G10 | built (0.5.0, [#192](https://github.com/APRS-NET/AprsMap/issues/192)) | | F2.5 | Bulletins and announcements, group bulletins; ANSRVR group join and leave; Send via checkmarks as for beacons | ANSRVR | App | G10 | scoped (0.5.0, [#193](https://github.com/APRS-NET/AprsMap/issues/193)) | | F2.6 | Email via the APRS email gateway, and the QRU/query service helpers | Email, QRU | App | G10 | scoped (0.6.0, [#201](https://github.com/APRS-NET/AprsMap/issues/201)) | | F2.7 | Objects and items: create, move, re-beacon with decay, kill; multiline objects | Multiline | App | G01, G11 | scoped (0.6.0, [#199](https://github.com/APRS-NET/AprsMap/issues/199)) | diff --git a/src/AprsMap.Core/Messenger.cs b/src/AprsMap.Core/Messenger.cs index 0ef617be..bb309430 100644 --- a/src/AprsMap.Core/Messenger.cs +++ b/src/AprsMap.Core/Messenger.cs @@ -207,6 +207,24 @@ .. _messages ); }) .OrderByDescending(c => c.Last?.At), + // Started, with Send via or retries chosen, before the first message. + .. _conversations + .Keys.Where(k => + !_messages.Any(m => string.Equals(m.Correspondent, k, StringComparison.OrdinalIgnoreCase)) + ) + .Select(k => + { + var (via, retries) = Settings(k); + var route = PlanRoute(k, via); + return new Conversation( + k, + 0, + null, + via, + retries, + route is null ? null : string.Join(", ", route.SourceIds.Select(NameOf)) + ); + }), ]; } } diff --git a/src/AprsMap.UI/Localization/Strings.resx b/src/AprsMap.UI/Localization/Strings.resx index 79b20598..b7ba9fb8 100644 --- a/src/AprsMap.UI/Localization/Strings.resx +++ b/src/AprsMap.UI/Localization/Strings.resx @@ -1041,6 +1041,106 @@ Start the gateway (for example with docker compose up), then open the app from t Details Panel title: details of the selected station. + + Messages + Panel title: conversations with other stations. + + + Message a callsign + + + Start + Opens a conversation with the callsign typed. + + + Conversations + + + No messages yet + + + {station}, {unread, plural, =0 {no unread messages} one {# unread} other {# unread}}: {last} + + + Write a message + + + {messages, plural, one {# message} other {# messages}} · {length} of 300 + + + Send + + + Cancel + Stops sending a message. + + + Send again + + + Send via {sources} + sources: where the next message goes, such as "Direwolf" or "rotate.aprs2.net". + + + Send via: nothing can send now, so messages wait + + + Automatic + + + Over RF to a station your TNC heard in the last 30 minutes, over APRS-IS otherwise, and once more over APRS-IS if RF goes unanswered. + + + Retries + + + Automatic + + + RF (6 tries over 23 minutes) + + + APRS-IS (4 tries over 13 minutes) + + + Slow HF modem (5 tries over 46 minutes) + + + Send once + + + Received + + + Not acknowledged: transmitting is off + + + Waiting to send + + + Sent + + + Sent again, try {try} of {of} + + + Delivered + + + Rejected by the station + + + Not delivered + + + Cancelled + + + {count, plural, one {# new message} other {# new messages}} + + + Send message + Packets Panel title: the live list of received packets. diff --git a/src/AprsMap.UI/Messages/MessagesViewModel.cs b/src/AprsMap.UI/Messages/MessagesViewModel.cs new file mode 100644 index 00000000..46d7afe4 --- /dev/null +++ b/src/AprsMap.UI/Messages/MessagesViewModel.cs @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Collections.ObjectModel; +using AprsMap.Contracts; +using AprsMap.UI.Localization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace AprsMap.UI.Messages; + +/// +/// The Messages panel (docs/features/F2.4-messaging.md, behaviors 1–11): the conversations, the open one's messages, +/// the compose box, and the conversation's Send via and retries. Everything comes from the engine's message stream. +/// +public sealed partial class MessagesViewModel : ObservableObject +{ + private readonly IEngineService _engine; + private readonly Localizer _text; + private readonly TimeProvider _time; + private readonly Func _status; + private readonly Dictionary _messages = new(StringComparer.Ordinal); + private readonly List _started = []; // conversations opened from New message, before their first message + + internal MessagesViewModel(IEngineService engine, Localizer text, TimeProvider time, Func status) + { + _engine = engine; + _text = text; + _time = time; + _status = status; + Retries = + [ + .. Enum.GetValues().Select(r => new RetriesChoice(r, _text[$"Messages.Retries.{r}"])), + ]; + } + + /// The conversations, newest activity first (behavior 1). + public ObservableCollection Conversations { get; } = []; + + /// The open conversation. + [ObservableProperty] + public partial ConversationRow? Selected { get; set; } + + /// The open conversation's messages, oldest first. + public ObservableCollection Thread { get; } = []; + + /// What's being written (behavior 6). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ComposeCount))] + [NotifyCanExecuteChangedFor(nameof(SendCommand))] + public partial string Compose { get; set; } = string.Empty; + + /// "2 messages · 120 of 300": how many messages the text makes. + public string ComposeCount => + Compose.Trim().Length == 0 + ? string.Empty + : _text.Format( + "Messages.ComposeCount", + new Dictionary + { + ["messages"] = Split(Compose.Trim()).Count, + ["length"] = Compose.Trim().Length, + } + ); + + /// The callsign typed for New message (behavior 3). + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(StartCommand))] + public partial string NewCallsign { get; set; } = string.Empty; + + /// Why something couldn't be done, or null. + [ObservableProperty] + public partial string? Error { get; private set; } + + /// + /// Why the panel can't show messages here, or null: a gateway's browser can't read them until operator sign-in + /// (behavior 16). + /// + [ObservableProperty] + public partial string? Unavailable { get; private set; } + + /// "via Direwolf" or "can't send now": which way the next message goes (behavior 8). + [ObservableProperty] + public partial string? RouteText { get; private set; } + + /// Automatic Send via for the open conversation. + [ObservableProperty] + public partial bool Automatic { get; set; } = true; + + /// The sources to tick when not automatic: the profile's TX-enabled ones. + public ObservableCollection Sources { get; } = []; + + /// The retry choices (behavior 9). + public IReadOnlyList Retries { get; } + + /// The open conversation's retries. + [ObservableProperty] + public partial RetriesChoice? SelectedRetries { get; set; } + + /// Unread messages in every conversation (behavior 4). + public int UnreadTotal => _messages.Values.Count(m => !m.Outgoing && !m.Read); + + private bool _showingSettings; + + /// Follows the engine's messages until cancelled; on a gateway's browser, says why it can't. + internal async Task WatchAsync(CancellationToken cancellationToken) + { + try + { + Unavailable = null; + await foreach (var message in _engine.WatchMessagesAsync(cancellationToken)) + { + Apply(message); + } + } + catch (UnauthorizedAccessException ex) + { + Unavailable = _text.Describe(ex); + await Task.Delay(Timeout.InfiniteTimeSpan, _time, cancellationToken); // nothing to retry until sign-in + } + } + + /// Takes one message from the stream: new, or a new state of one already shown. + internal void Apply(ChatMessage message) + { + _messages[message.Id] = message; + _started.RemoveAll(c => string.Equals(c, message.Correspondent, StringComparison.OrdinalIgnoreCase)); + RebuildConversations(); + if (Selected is { } open && Same(open.Correspondent, message.Correspondent)) + { + var row = Thread.FirstOrDefault(r => r.Message.Id == message.Id); + if (row is null) + { + Thread.Add(new MessageRow(message, _text, this)); + } + else + { + row.Show(message); + } + if (!message.Outgoing && !message.Read) + { + _ = MarkReadAsync(open.Correspondent); // the conversation is open: read (behavior 4) + } + } + OnPropertyChanged(nameof(UnreadTotal)); + } + + partial void OnSelectedChanged(ConversationRow? value) + { + Thread.Clear(); + Error = null; + if (value is null) + { + return; + } + foreach ( + var message in _messages.Values.Where(m => Same(m.Correspondent, value.Correspondent)).OrderBy(m => m.At) + ) + { + Thread.Add(new MessageRow(message, _text, this)); + } + if (value.Unread > 0) + { + _ = MarkReadAsync(value.Correspondent); + } + _ = LoadSettingsAsync(value.Correspondent); + SendCommand.NotifyCanExecuteChanged(); + } + + /// Loads the conversation's Send via, retries and route (behaviors 8, 9). + private async Task LoadSettingsAsync(string correspondent) + { + Sources.Clear(); + foreach (var source in (_status()?.Sources ?? []).Where(s => s.TransmitEnabled)) + { + Sources.Add(new SourceChoice(source.Id, source.Name, this)); + } + try + { + var conversation = (await _engine.GetConversationsAsync()).FirstOrDefault(c => + Same(c.Correspondent, correspondent) + ); + _showingSettings = true; + Automatic = conversation?.SendVia.Automatic ?? true; + foreach (var source in Sources) + { + source.IsChecked = conversation?.SendVia.SourceIds?.Contains(source.Id) ?? false; + } + SelectedRetries = Retries.First(r => r.Value == (conversation?.Retries ?? MessageRetries.Automatic)); + _showingSettings = false; + ShowRoute(conversation); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException) + { + _showingSettings = false; + Error = _text.Describe(ex); + } + } + + /// Says which way the next message goes (behavior 8). + private void ShowRoute(Conversation? conversation) => + RouteText = conversation?.Route is { } route + ? _text.Format("Messages.Route", new Dictionary { ["sources"] = route }) + : _text["Messages.RouteNone"]; + + /// Asks the engine which way the open conversation's next message goes, keeping what's shown ticked. + private async Task RefreshRouteAsync(string correspondent) + { + try + { + ShowRoute( + (await _engine.GetConversationsAsync()).FirstOrDefault(c => Same(c.Correspondent, correspondent)) + ); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException) + { + Error = _text.Describe(ex); + } + } + + partial void OnAutomaticChanged(bool value) => _ = SaveSettingsAsync(); + + partial void OnSelectedRetriesChanged(RetriesChoice? value) => _ = SaveSettingsAsync(); + + /// Saves the open conversation's Send via and retries. + internal async Task SaveSettingsAsync() + { + if (_showingSettings || Selected is not { } open || SelectedRetries is not { } retries) + { + return; + } + var via = Automatic ? SendVia.Auto : new SendVia([.. Sources.Where(s => s.IsChecked).Select(s => s.Id)]); + try + { + await _engine.SetConversationAsync(open.Correspondent, via, retries.Value); + await RefreshRouteAsync(open.Correspondent); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException) + { + Error = _text.Describe(ex); + } + } + + private bool CanSend() => Selected is not null && Compose.Trim().Length > 0; + + /// Sends what's written to the open conversation (behavior 6). + [RelayCommand(CanExecute = nameof(CanSend))] + private async Task SendAsync() + { + if (Selected is not { } open) + { + return; + } + try + { + await _engine.SendMessageAsync(open.Correspondent, Compose.Trim()); + Compose = string.Empty; + Error = null; + await RefreshRouteAsync(open.Correspondent); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or UnauthorizedAccessException) + { + Error = _text.Describe(ex); + } + } + + private bool CanStart() => NewCallsign.Trim().Length > 0; + + /// Opens a conversation with the callsign typed, empty until the first message (behavior 3). + [RelayCommand(CanExecute = nameof(CanStart))] + private void Start() + { + Open(NewCallsign); + NewCallsign = string.Empty; + } + + /// Opens the conversation with , from a station's details too. + internal void Open(string correspondent) + { + var call = correspondent.Trim().ToUpperInvariant(); + if (call.Length == 0) + { + return; + } + if (!Conversations.Any(c => Same(c.Correspondent, call))) + { + _started.Add(call); + RebuildConversations(); + } + Selected = Conversations.First(c => Same(c.Correspondent, call)); + } + + internal async Task CancelAsync(MessageRow row) => await Try(() => _engine.CancelMessageAsync(row.Message.Id)); + + internal async Task ResendAsync(MessageRow row) => await Try(() => _engine.ResendMessageAsync(row.Message.Id)); + + private async Task MarkReadAsync(string correspondent) => await Try(() => _engine.MarkReadAsync(correspondent)); + + private async Task Try(Func act) + { + try + { + await act(); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException) + { + Error = _text.Describe(ex); + } + } + + /// Rebuilds the conversation list from the messages, keeping the open one open. + private void RebuildConversations() + { + var groups = _messages + .Values.GroupBy(m => m.Correspondent, StringComparer.OrdinalIgnoreCase) + .Select(g => (Correspondent: g.Key, Last: g.MaxBy(m => m.At), Unread: g.Count(m => !m.Outgoing && !m.Read))) + .OrderByDescending(g => g.Last?.At) + .ToList(); + var wanted = groups + .Select(g => g.Correspondent) + .Concat(_started.Where(s => !groups.Any(g => Same(g.Correspondent, s)))) + .ToList(); + for (var i = Conversations.Count - 1; i >= 0; i--) + { + if (!wanted.Any(w => Same(w, Conversations[i].Correspondent))) + { + Conversations.RemoveAt(i); + } + } + for (var i = 0; i < wanted.Count; i++) + { + var row = Conversations.FirstOrDefault(c => Same(c.Correspondent, wanted[i])); + if (row is null) + { + row = new ConversationRow(wanted[i]); + Conversations.Insert(Math.Min(i, Conversations.Count), row); + } + else if (Conversations.IndexOf(row) != i) + { + Conversations.Move(Conversations.IndexOf(row), i); + } + var group = groups.FirstOrDefault(g => Same(g.Correspondent, wanted[i])); + row.Show(group.Last, group.Unread, _text); + } + } + + /// The parts is sent as: at most 67 characters, between words (behavior 6). + internal static List Split(string text) + { + var parts = new List(); + var rest = text; + while (rest.Length > 67) + { + var cut = rest.LastIndexOf(' ', 67); + cut = cut <= 0 ? 67 : cut; + parts.Add(rest[..cut].TrimEnd()); + rest = rest[cut..].TrimStart(); + } + if (rest.Length > 0) + { + parts.Add(rest); + } + return parts; + } + + private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); +} + +/// A conversation in the list. +public sealed partial class ConversationRow(string correspondent) : ObservableObject +{ + /// The other station. + public string Correspondent { get; } = correspondent; + + /// The newest message's text, or "No messages yet". + [ObservableProperty] + public partial string LastText { get; private set; } = string.Empty; + + /// Unread received messages. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUnread))] + public partial int Unread { get; private set; } + + /// Whether to show the unread count. + public bool HasUnread => Unread > 0; + + /// "N1ABC, 2 unread: Hello", for screen readers. + [ObservableProperty] + public partial string Spoken { get; private set; } = string.Empty; + + internal void Show(ChatMessage? last, int unread, Localizer text) + { + LastText = last?.Text ?? text["Messages.NoMessagesYet"]; + Unread = unread; + Spoken = text.Format( + "Messages.ConversationSpoken", + new Dictionary + { + ["station"] = Correspondent, + ["unread"] = unread, + ["last"] = LastText, + } + ); + } +} + +/// One message in the open conversation (behavior 7). +public sealed partial class MessageRow : ObservableObject +{ + private readonly Localizer _text; + + internal MessageRow(ChatMessage message, Localizer text, MessagesViewModel owner) + { + _text = text; + Message = message; + CancelCommand = new AsyncRelayCommand(() => owner.CancelAsync(this), () => CanCancel); + ResendCommand = new AsyncRelayCommand(() => owner.ResendAsync(this), () => CanResend); + Show(message); + } + + /// The message as last streamed. + [ObservableProperty] + public partial ChatMessage Message { get; private set; } + + /// Sent by this station: shown on the other side. + public bool Outgoing => Message.Outgoing; + + /// The text. + public string Text => Message.Text; + + /// "Delivered", "Retrying, try 2 of 6", "Not acknowledged: transmitting is off", in words. + [ObservableProperty] + public partial string StateText { get; private set; } = string.Empty; + + /// Failed or rejected: the warning colour, said in words too. + [ObservableProperty] + public partial bool IsProblem { get; private set; } + + /// Whether Cancel is offered: queued or retrying. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(CancelCommand))] + public partial bool CanCancel { get; private set; } + + /// Whether Send again is offered: failed, rejected or cancelled. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ResendCommand))] + public partial bool CanResend { get; private set; } + + /// Stops a queued or retrying message. + public IAsyncRelayCommand CancelCommand { get; } + + /// Sends a finished message again. + public IAsyncRelayCommand ResendCommand { get; } + + internal void Show(ChatMessage message) + { + Message = message; + OnPropertyChanged(nameof(Text)); + var time = message.At.ToLocalTime().ToString("t", _text.Culture); + var state = message.State switch + { + ChatMessageState.Received when message.NotAcknowledged => _text["Messages.State.NotAcknowledged"], + ChatMessageState.Retrying => _text.Format( + "Messages.State.Retrying", + new Dictionary { ["try"] = message.Attempts, ["of"] = message.MaxAttempts } + ), + _ => _text[$"Messages.State.{message.State}"], + }; + StateText = time + " · " + state; + IsProblem = message.State is ChatMessageState.Failed or ChatMessageState.Rejected || message.NotAcknowledged; + CanCancel = message.State is ChatMessageState.Queued or ChatMessageState.Sent or ChatMessageState.Retrying; + CanResend = message.State is ChatMessageState.Failed or ChatMessageState.Rejected or ChatMessageState.Cancelled; + } +} + +/// A source to tick in Send via (behavior 8.2). +public sealed partial class SourceChoice(string id, string name, MessagesViewModel owner) : ObservableObject +{ + /// The source's id. + public string Id { get; } = id; + + /// Its name. + public string Name { get; } = name; + + /// Ticked. + [ObservableProperty] + public partial bool IsChecked { get; set; } + + partial void OnIsCheckedChanged(bool value) => _ = owner.SaveSettingsAsync(); +} + +/// A retry choice, with its name (behavior 9). +public sealed record RetriesChoice(MessageRetries Value, string Name) +{ + /// + public override string ToString() => Name; +} diff --git a/src/AprsMap.UI/Shell/ShellModel.cs b/src/AprsMap.UI/Shell/ShellModel.cs index fd87259b..77059152 100644 --- a/src/AprsMap.UI/Shell/ShellModel.cs +++ b/src/AprsMap.UI/Shell/ShellModel.cs @@ -40,9 +40,17 @@ public static class PanelKinds /// The live packet list (F1.8). public const string Packets = "packets"; + /// The conversations (docs/features/F2.4-messaging.md). + public const string Messages = "messages"; + /// Every panel, with the id of the dock it starts in (behavior 2). public static IReadOnlyList<(string Kind, string Dock)> All { get; } = - [(Stations, ShellFactory.LeftDock), (Details, ShellFactory.RightDock), (Packets, ShellFactory.BottomDock)]; + [ + (Stations, ShellFactory.LeftDock), + (Details, ShellFactory.RightDock), + (Messages, ShellFactory.RightDock), + (Packets, ShellFactory.BottomDock), + ]; /// The resource key of a panel's title. public static string TitleKey(string kind) => "Shell.Panel." + kind; diff --git a/src/AprsMap.UI/Stations/StationDetailsViewModel.cs b/src/AprsMap.UI/Stations/StationDetailsViewModel.cs index 94ad63b6..4b571c38 100644 --- a/src/AprsMap.UI/Stations/StationDetailsViewModel.cs +++ b/src/AprsMap.UI/Stations/StationDetailsViewModel.cs @@ -529,6 +529,19 @@ private void Centre() } } + /// Opens a conversation with the station in the Messages panel (docs/features/F2.4-messaging.md, behavior 3). + [RelayCommand] + private void SendMessage() + { + if (Name is { } name) + { + MessageRequested?.Invoke(this, name); + } + } + + /// Asks for a conversation with the station named. + public event EventHandler? MessageRequested; + /// Clears the selection. [RelayCommand] private void Close() => CloseRequested?.Invoke(this, EventArgs.Empty); diff --git a/src/AprsMap.UI/ViewModels/MainViewModel.cs b/src/AprsMap.UI/ViewModels/MainViewModel.cs index a7454497..e65464e1 100644 --- a/src/AprsMap.UI/ViewModels/MainViewModel.cs +++ b/src/AprsMap.UI/ViewModels/MainViewModel.cs @@ -68,6 +68,7 @@ public MainViewModel( name => _settings is { ReadOnly: false } && _tracks.TryGetValue(name, out var track) && track.Count > 1 ); Details.CloseRequested += (_, _) => SelectedStation = null; + Details.MessageRequested += (_, name) => OpenConversation(name); Details.SelectRequested += (_, name) => SelectedStation = name; Details.PropertyChanged += (_, e) => { @@ -216,6 +217,27 @@ public LayoutRestore RestoreLayout(string? saved, IReadOnlyList scre /// The dashboard tab (F1.22). public Statistics.DashboardViewModel Dashboard { get; } + /// The Messages panel (docs/features/F2.4-messaging.md). + public Messages.MessagesViewModel Messages => + _messagesPanel ??= new Messages.MessagesViewModel(engine, _text, time, () => _status); + + private Messages.MessagesViewModel? _messagesPanel; + + /// Opens the Messages panel on the conversation with (behavior 3). + internal void OpenConversation(string correspondent) + { + Messages.Open(correspondent); + ShowPanel(PanelKinds.Messages); + } + + /// "2 new messages" in the status bar while any are unread (behavior 4); null otherwise. + [ObservableProperty] + public partial string? UnreadText { get; private set; } + + /// Opens the Messages panel, from the unread marker. + [RelayCommand] + private void ShowMessages() => ShowPanel(PanelKinds.Messages); + /// The packet list's rows, newest first. public PacketRows Packets => PacketList.Rows; @@ -594,7 +616,8 @@ public async Task StartAsync(CancellationToken cancellationToken = default) var stations = KeepAsync(WatchStationsAsync, cancellationToken); var status = KeepAsync(PollStatusAsync, cancellationToken); var countdown = KeepAsync(CountDownAsync, cancellationToken); - await Task.WhenAll(packets, stations, status, countdown); + var messages = KeepAsync(Messages.WatchAsync, cancellationToken); + await Task.WhenAll(packets, stations, status, countdown, messages); } /// Applies one batch from the station stream. @@ -1187,6 +1210,13 @@ internal void Show(EngineStatus status) _engineUnreachable = false; ShowConnection(); ShowSourcePills(status); // after the transmit state, which their Send position now follows + UnreadText = + status.UnreadMessages > 0 + ? _text.Format( + "Messages.UnreadMarker", + new Dictionary { ["count"] = status.UnreadMessages } + ) + : null; } /// Runs until cancelled, retrying after failures and after the stream ends. diff --git a/src/AprsMap.UI/Views/DetailsPanel.axaml b/src/AprsMap.UI/Views/DetailsPanel.axaml index a3eb969c..e3f37539 100644 --- a/src/AprsMap.UI/Views/DetailsPanel.axaml +++ b/src/AprsMap.UI/Views/DetailsPanel.axaml @@ -84,6 +84,11 @@