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 @@