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