diff --git a/Content.Client/Access/UI/AgentIDCardBoundUserInterface.cs b/Content.Client/Access/UI/AgentIDCardBoundUserInterface.cs
index 050756fcd14..24e9bc7a01d 100644
--- a/Content.Client/Access/UI/AgentIDCardBoundUserInterface.cs
+++ b/Content.Client/Access/UI/AgentIDCardBoundUserInterface.cs
@@ -26,8 +26,16 @@ protected override void Open()
_window.OnNameChanged += OnNameChanged;
_window.OnJobChanged += OnJobChanged;
_window.OnJobIconChanged += OnJobIconChanged;
+ _window.OnNumberChanged += OnNumberChanged; // Corvax-Next-PDAChat
}
+ // Corvax-Next-PDAChat-Start
+ private void OnNumberChanged(uint newNumber)
+ {
+ SendMessage(new AgentIDCardNumberChangedMessage(newNumber));
+ }
+ // Corvax-Next-PDAChat-End
+
private void OnNameChanged(string newName)
{
SendMessage(new AgentIDCardNameChangedMessage(newName));
@@ -56,6 +64,7 @@ protected override void UpdateState(BoundUserInterfaceState state)
_window.SetCurrentName(cast.CurrentName);
_window.SetCurrentJob(cast.CurrentJob);
_window.SetAllowedIcons(cast.CurrentJobIconId);
+ _window.SetCurrentNumber(cast.CurrentNumber); // Corvax-Next-PDAChat
}
}
}
diff --git a/Content.Client/Access/UI/AgentIDCardWindow.xaml b/Content.Client/Access/UI/AgentIDCardWindow.xaml
index 7d091e4e165..a61ed2a5ae2 100644
--- a/Content.Client/Access/UI/AgentIDCardWindow.xaml
+++ b/Content.Client/Access/UI/AgentIDCardWindow.xaml
@@ -6,6 +6,10 @@
+
+
+
+
diff --git a/Content.Client/Access/UI/AgentIDCardWindow.xaml.cs b/Content.Client/Access/UI/AgentIDCardWindow.xaml.cs
index 320bb88a67e..98b20d74057 100644
--- a/Content.Client/Access/UI/AgentIDCardWindow.xaml.cs
+++ b/Content.Client/Access/UI/AgentIDCardWindow.xaml.cs
@@ -21,9 +21,13 @@ public sealed partial class AgentIDCardWindow : DefaultWindow
private const int JobIconColumnCount = 10;
+ private const int MaxNumberLength = 4; // Corvax-Next-PDAChat - Same as NewChatPopup
+
public event Action? OnNameChanged;
public event Action? OnJobChanged;
+ public event Action? OnNumberChanged; // Corvax-Next-PDAChat - Add event for number changes
+
public event Action>? OnJobIconChanged;
public AgentIDCardWindow()
@@ -35,9 +39,42 @@ public AgentIDCardWindow()
NameLineEdit.OnTextEntered += e => OnNameChanged?.Invoke(e.Text);
NameLineEdit.OnFocusExit += e => OnNameChanged?.Invoke(e.Text);
+ // Corvax-Next-PDAChat-Start
JobLineEdit.OnTextEntered += e => OnJobChanged?.Invoke(e.Text);
JobLineEdit.OnFocusExit += e => OnJobChanged?.Invoke(e.Text);
+
+ // Corvax-Next-PDAChat - Add handlers for number changes
+ NumberLineEdit.OnTextEntered += OnNumberEntered;
+ NumberLineEdit.OnFocusExit += OnNumberEntered;
+
+ // Corvax-Next-PDAChat - Filter to only allow digits
+ NumberLineEdit.OnTextChanged += args =>
+ {
+ if (args.Text.Length > MaxNumberLength)
+ {
+ NumberLineEdit.Text = args.Text[..MaxNumberLength];
+ }
+
+ // Filter to digits only
+ var newText = string.Concat(args.Text.Where(char.IsDigit));
+ if (newText != args.Text)
+ NumberLineEdit.Text = newText;
+ };
+ }
+
+ // Corvax-Next-PDAChat - Add number validation and event
+ private void OnNumberEntered(LineEdit.LineEditEventArgs args)
+ {
+ if (uint.TryParse(args.Text, out var number) && number > 0)
+ OnNumberChanged?.Invoke(number);
+ }
+
+ // Corvax-Next-PDAChat - Add setter for current number
+ public void SetCurrentNumber(uint? number)
+ {
+ NumberLineEdit.Text = number?.ToString("D4") ?? "";
}
+ // Corvax-Next-PDAChat-End
public void SetAllowedIcons(string currentJobIconId)
{
diff --git a/Content.Client/CartridgeLoader/Cartridges/LogProbeUi.cs b/Content.Client/CartridgeLoader/Cartridges/LogProbeUi.cs
index d28d3228c94..7ece0abcbd9 100644
--- a/Content.Client/CartridgeLoader/Cartridges/LogProbeUi.cs
+++ b/Content.Client/CartridgeLoader/Cartridges/LogProbeUi.cs
@@ -23,6 +23,6 @@ public override void UpdateState(BoundUserInterfaceState state)
if (state is not LogProbeUiState logProbeUiState)
return;
- _fragment?.UpdateState(logProbeUiState.PulledLogs);
+ _fragment?.UpdateState(logProbeUiState); // Corvax-Next-PDAChat - just take the state
}
}
diff --git a/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml b/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml
index d12fb55cdce..7c571e08f9b 100644
--- a/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml
+++ b/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml
@@ -9,10 +9,28 @@
BorderColor="#5a5a5a"
BorderThickness="0 0 0 1"/>
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml.cs b/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml.cs
index b22e0bc1964..a16c92a066c 100644
--- a/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml.cs
+++ b/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml.cs
@@ -1,4 +1,7 @@
-using Content.Shared.CartridgeLoader.Cartridges;
+using System.Linq;
+using Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
+using Content.Shared.CartridgeLoader.Cartridges;
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
@@ -13,10 +16,112 @@ public LogProbeUiFragment()
RobustXamlLoader.Load(this);
}
- public void UpdateState(List logs)
+ // Corvax-Next-PDAChat-Start - Update to handle both types of data
+ public void UpdateState(LogProbeUiState state)
{
ProbedDeviceContainer.RemoveAllChildren();
+ if (state.NanoChatData != null)
+ {
+ SetupNanoChatView(state.NanoChatData.Value);
+ DisplayNanoChatData(state.NanoChatData.Value);
+ }
+ else
+ {
+ SetupAccessLogView();
+ if (state.PulledLogs.Count > 0)
+ DisplayAccessLogs(state.PulledLogs);
+ }
+ }
+
+ private void SetupNanoChatView(NanoChatData data)
+ {
+ TitleLabel.Text = Loc.GetString("log-probe-header-nanochat");
+ ContentLabel.Text = Loc.GetString("log-probe-label-message");
+
+ // Show card info if available
+ var cardInfo = new List();
+ if (data.CardNumber != null)
+ cardInfo.Add(Loc.GetString("log-probe-card-number", ("number", $"#{data.CardNumber:D4}")));
+
+ // Add recipient count
+ cardInfo.Add(Loc.GetString("log-probe-recipients", ("count", data.Recipients.Count)));
+
+ CardNumberLabel.Text = string.Join(" | ", cardInfo);
+ CardNumberLabel.Visible = true;
+ }
+
+ private void SetupAccessLogView()
+ {
+ TitleLabel.Text = Loc.GetString("log-probe-header-access");
+ ContentLabel.Text = Loc.GetString("log-probe-label-accessor");
+ CardNumberLabel.Visible = false;
+ }
+
+ private void DisplayNanoChatData(NanoChatData data)
+ {
+ // First add a recipient list entry
+ var recipientsList = Loc.GetString("log-probe-recipient-list") + "\n" + string.Join("\n",
+ data.Recipients.Values
+ .OrderBy(r => r.Name)
+ .Select(r => $" {r.Name}" +
+ (string.IsNullOrEmpty(r.JobTitle) ? "" : $" ({r.JobTitle})") +
+ $" | #{r.Number:D4}"));
+
+ var recipientsEntry = new LogProbeUiEntry(0, "---", recipientsList);
+ ProbedDeviceContainer.AddChild(recipientsEntry);
+
+ var count = 1;
+ foreach (var (partnerId, messages) in data.Messages)
+ {
+ // Show only successfully delivered incoming messages
+ var incomingMessages = messages
+ .Where(msg => msg.SenderId == partnerId && !msg.DeliveryFailed)
+ .OrderByDescending(msg => msg.Timestamp);
+
+ foreach (var msg in incomingMessages)
+ {
+ var messageText = Loc.GetString("log-probe-message-format",
+ ("sender", $"#{msg.SenderId:D4}"),
+ ("recipient", $"#{data.CardNumber:D4}"),
+ ("content", msg.Content));
+
+ var entry = new NanoChatLogEntry(
+ count,
+ TimeSpan.FromSeconds(Math.Truncate(msg.Timestamp.TotalSeconds)).ToString(),
+ messageText);
+
+ ProbedDeviceContainer.AddChild(entry);
+ count++;
+ }
+
+ // Show only successfully delivered outgoing messages
+ var outgoingMessages = messages
+ .Where(msg => msg.SenderId == data.CardNumber && !msg.DeliveryFailed)
+ .OrderByDescending(msg => msg.Timestamp);
+
+ foreach (var msg in outgoingMessages)
+ {
+ var messageText = Loc.GetString("log-probe-message-format",
+ ("sender", $"#{msg.SenderId:D4}"),
+ ("recipient", $"#{partnerId:D4}"),
+ ("content", msg.Content));
+
+ var entry = new NanoChatLogEntry(
+ count,
+ TimeSpan.FromSeconds(Math.Truncate(msg.Timestamp.TotalSeconds)).ToString(),
+ messageText);
+
+ ProbedDeviceContainer.AddChild(entry);
+ count++;
+ }
+ }
+ }
+ // Corvax-Next-PDAChat-End
+
+ // Corvax-Next-PDAChat - Handle this in a separate method
+ private void DisplayAccessLogs(List logs)
+ {
//Reverse the list so the oldest entries appear at the bottom
logs.Reverse();
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml
new file mode 100644
index 00000000000..0b136133624
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml
@@ -0,0 +1,48 @@
+
+
+
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml.cs
new file mode 100644
index 00000000000..2a2b665d929
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml.cs
@@ -0,0 +1,39 @@
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
+
+[GenerateTypedNameReferences]
+public sealed partial class NanoChatEntry : BoxContainer
+{
+ public event Action? OnPressed;
+ private uint _number;
+ private Action? _pressHandler;
+
+ public NanoChatEntry()
+ {
+ RobustXamlLoader.Load(this);
+ }
+
+ public void SetRecipient(NanoChatRecipient recipient, uint number, bool isSelected)
+ {
+ // Remove old handler if it exists
+ if (_pressHandler != null)
+ ChatButton.OnPressed -= _pressHandler;
+
+ _number = number;
+
+ // Create and store new handler
+ _pressHandler = _ => OnPressed?.Invoke(_number);
+ ChatButton.OnPressed += _pressHandler;
+
+ NameLabel.Text = recipient.Name;
+ JobLabel.Text = recipient.JobTitle ?? "";
+ JobLabel.Visible = !string.IsNullOrEmpty(recipient.JobTitle);
+ UnreadIndicator.Visible = recipient.HasUnread;
+
+ ChatButton.ModulateSelfOverride = isSelected ? NanoChatMessageBubble.OwnMessageColor : null;
+ }
+}
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml
new file mode 100644
index 00000000000..c87478d6301
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml.cs
new file mode 100644
index 00000000000..48e1eeaa05b
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml.cs
@@ -0,0 +1,17 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
+
+[GenerateTypedNameReferences]
+public sealed partial class NanoChatLogEntry : BoxContainer
+{
+ public NanoChatLogEntry(int number, string time, string message)
+ {
+ RobustXamlLoader.Load(this);
+ NumberLabel.Text = number.ToString();
+ TimeLabel.Text = time;
+ MessageLabel.Text = message;
+ }
+}
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml
new file mode 100644
index 00000000000..7f4126213f9
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml.cs
new file mode 100644
index 00000000000..b903d153afa
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml.cs
@@ -0,0 +1,62 @@
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
+
+[GenerateTypedNameReferences]
+public sealed partial class NanoChatMessageBubble : BoxContainer
+{
+ public static readonly Color OwnMessageColor = Color.FromHex("#173717d9"); // Dark green
+ public static readonly Color OtherMessageColor = Color.FromHex("#252525d9"); // Dark gray
+ public static readonly Color BorderColor = Color.FromHex("#40404066"); // Subtle border
+ public static readonly Color TextColor = Color.FromHex("#dcdcdc"); // Slightly softened white
+ public static readonly Color ErrorColor = Color.FromHex("#cc3333"); // Red
+
+ public NanoChatMessageBubble()
+ {
+ RobustXamlLoader.Load(this);
+ }
+
+ public void SetMessage(NanoChatMessage message, bool isOwnMessage)
+ {
+ if (MessagePanel.PanelOverride is not StyleBoxFlat)
+ return;
+
+ // Configure message appearance
+ var style = (StyleBoxFlat)MessagePanel.PanelOverride;
+ style.BackgroundColor = isOwnMessage ? OwnMessageColor : OtherMessageColor;
+ style.BorderColor = BorderColor;
+
+ // Set message content
+ MessageText.Text = message.Content;
+ MessageText.Modulate = TextColor;
+
+ // Show delivery failed text if needed (only for own messages)
+ DeliveryFailedLabel.Visible = isOwnMessage && message.DeliveryFailed;
+ if (DeliveryFailedLabel.Visible)
+ DeliveryFailedLabel.Modulate = ErrorColor;
+
+ // For own messages: FlexSpace -> MessagePanel -> RightSpacer
+ // For other messages: LeftSpacer -> MessagePanel -> FlexSpace
+ MessageContainer.RemoveAllChildren();
+
+ // fuuuuuck
+ MessageBox.Parent?.RemoveChild(MessageBox);
+
+ if (isOwnMessage)
+ {
+ MessageContainer.AddChild(FlexSpace);
+ MessageContainer.AddChild(MessageBox);
+ MessageContainer.AddChild(RightSpacer);
+ }
+ else
+ {
+ MessageContainer.AddChild(LeftSpacer);
+ MessageContainer.AddChild(MessageBox);
+ MessageContainer.AddChild(FlexSpace);
+ }
+ }
+}
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUi.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUi.cs
new file mode 100644
index 00000000000..1573132297e
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUi.cs
@@ -0,0 +1,43 @@
+using Content.Client.UserInterface.Fragments;
+using Content.Shared.CartridgeLoader;
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
+
+public sealed partial class NanoChatUi : UIFragment
+{
+ private NanoChatUiFragment? _fragment;
+
+ public override Control GetUIFragmentRoot()
+ {
+ return _fragment!;
+ }
+
+ public override void Setup(BoundUserInterface userInterface, EntityUid? fragmentOwner)
+ {
+ _fragment = new NanoChatUiFragment();
+
+ _fragment.OnMessageSent += (type, number, content, job) =>
+ {
+ SendNanoChatUiMessage(type, number, content, job, userInterface);
+ };
+ }
+
+ public override void UpdateState(BoundUserInterfaceState state)
+ {
+ if (state is NanoChatUiState cast)
+ _fragment?.UpdateState(cast);
+ }
+
+ private static void SendNanoChatUiMessage(NanoChatUiMessageType type,
+ uint? number,
+ string? content,
+ string? job,
+ BoundUserInterface userInterface)
+ {
+ var nanoChatMessage = new NanoChatUiMessageEvent(type, number, content, job);
+ var message = new CartridgeUiMessage(nanoChatMessage);
+ userInterface.SendMessage(message);
+ }
+}
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml
new file mode 100644
index 00000000000..d0e54d2e58a
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml.cs
new file mode 100644
index 00000000000..045c00cba79
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml.cs
@@ -0,0 +1,254 @@
+using System.Linq;
+using System.Numerics;
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Client.UserInterface;
+using Robust.Shared.Timing;
+
+namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
+
+[GenerateTypedNameReferences]
+public sealed partial class NanoChatUiFragment : BoxContainer
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ private const int MaxMessageLength = 256;
+
+ private readonly NewChatPopup _newChatPopup;
+ private uint? _currentChat;
+ private uint? _pendingChat;
+ private uint _ownNumber;
+ private bool _notificationsMuted;
+ private Dictionary _recipients = new();
+ private Dictionary> _messages = new();
+
+ public event Action? OnMessageSent;
+
+ public NanoChatUiFragment()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ _newChatPopup = new NewChatPopup();
+ SetupEventHandlers();
+ }
+
+ private void SetupEventHandlers()
+ {
+ _newChatPopup.OnChatCreated += (number, name, job) =>
+ {
+ OnMessageSent?.Invoke(NanoChatUiMessageType.NewChat, number, name, job);
+ };
+
+ NewChatButton.OnPressed += _ =>
+ {
+ _newChatPopup.ClearInputs();
+ _newChatPopup.OpenCentered();
+ };
+
+ MuteButton.OnPressed += _ =>
+ {
+ _notificationsMuted = !_notificationsMuted;
+ UpdateMuteButton();
+ OnMessageSent?.Invoke(NanoChatUiMessageType.ToggleMute, null, null, null);
+ };
+
+ MessageInput.OnTextChanged += args =>
+ {
+ var length = args.Text.Length;
+ var isValid = !string.IsNullOrWhiteSpace(args.Text) &&
+ length <= MaxMessageLength &&
+ (_currentChat != null || _pendingChat != null);
+
+ SendButton.Disabled = !isValid;
+
+ // Show character count when over limit
+ CharacterCount.Visible = length > MaxMessageLength;
+ if (length > MaxMessageLength)
+ {
+ CharacterCount.Text = Loc.GetString("nano-chat-message-too-long",
+ ("current", length),
+ ("max", MaxMessageLength));
+ CharacterCount.StyleClasses.Add("LabelDanger");
+ }
+ };
+
+ SendButton.OnPressed += _ => SendMessage();
+ DeleteChatButton.OnPressed += _ => DeleteCurrentChat();
+ }
+
+ private void SendMessage()
+ {
+ var activeChat = _pendingChat ?? _currentChat;
+ if (activeChat == null || string.IsNullOrWhiteSpace(MessageInput.Text))
+ return;
+
+ var messageContent = MessageInput.Text;
+
+ // Add predicted message
+ var predictedMessage = new NanoChatMessage(
+ _timing.CurTime,
+ messageContent,
+ _ownNumber
+ );
+
+ if (!_messages.TryGetValue(activeChat.Value, out var value))
+ {
+ value = new List();
+ _messages[activeChat.Value] = value;
+ }
+
+ value.Add(predictedMessage);
+
+ // Update UI with predicted message
+ UpdateMessages(_messages);
+
+ // Send message event
+ OnMessageSent?.Invoke(NanoChatUiMessageType.SendMessage, activeChat, messageContent, null);
+
+ // Clear input
+ MessageInput.Text = string.Empty;
+ SendButton.Disabled = true;
+ }
+
+ private void SelectChat(uint number)
+ {
+ // Don't reselect the same chat
+ if (_currentChat == number && _pendingChat == null)
+ return;
+
+ _pendingChat = number;
+
+ // Predict marking messages as read
+ if (_recipients.TryGetValue(number, out var recipient))
+ {
+ recipient.HasUnread = false;
+ _recipients[number] = recipient;
+ UpdateChatList(_recipients);
+ }
+
+ OnMessageSent?.Invoke(NanoChatUiMessageType.SelectChat, number, null, null);
+ UpdateCurrentChat();
+ }
+
+ private void DeleteCurrentChat()
+ {
+ var activeChat = _pendingChat ?? _currentChat;
+ if (activeChat == null)
+ return;
+
+ OnMessageSent?.Invoke(NanoChatUiMessageType.DeleteChat, activeChat, null, null);
+ }
+
+ private void UpdateChatList(Dictionary recipients)
+ {
+ ChatList.RemoveAllChildren();
+ _recipients = recipients;
+
+ NoChatsLabel.Visible = recipients.Count == 0;
+ if (NoChatsLabel.Parent != ChatList)
+ {
+ NoChatsLabel.Parent?.RemoveChild(NoChatsLabel);
+ ChatList.AddChild(NoChatsLabel);
+ }
+
+ foreach (var (number, recipient) in recipients.OrderBy(r => r.Value.Name))
+ {
+ var entry = new NanoChatEntry();
+ // For pending chat selection, always show it as selected even if unconfirmed
+ var isSelected = (_pendingChat == number) || (_pendingChat == null && _currentChat == number);
+ entry.SetRecipient(recipient, number, isSelected);
+ entry.OnPressed += SelectChat;
+ ChatList.AddChild(entry);
+ }
+ }
+
+ private void UpdateCurrentChat()
+ {
+ var activeChat = _pendingChat ?? _currentChat;
+ var hasActiveChat = activeChat != null;
+
+ // Update UI state
+ MessagesScroll.Visible = hasActiveChat;
+ CurrentChatName.Visible = !hasActiveChat;
+ MessageInputContainer.Visible = hasActiveChat;
+ DeleteChatButton.Visible = hasActiveChat;
+ DeleteChatButton.Disabled = !hasActiveChat;
+
+ if (activeChat != null && _recipients.TryGetValue(activeChat.Value, out var recipient))
+ {
+ CurrentChatName.Text = recipient.Name + (string.IsNullOrEmpty(recipient.JobTitle) ? "" : $" ({recipient.JobTitle})");
+ }
+ else
+ {
+ CurrentChatName.Text = Loc.GetString("nano-chat-select-chat");
+ }
+ }
+
+ private void UpdateMessages(Dictionary> messages)
+ {
+ _messages = messages;
+ MessageList.RemoveAllChildren();
+
+ var activeChat = _pendingChat ?? _currentChat;
+ if (activeChat == null || !messages.TryGetValue(activeChat.Value, out var chatMessages))
+ return;
+
+ foreach (var message in chatMessages)
+ {
+ var messageBubble = new NanoChatMessageBubble();
+ messageBubble.SetMessage(message, message.SenderId == _ownNumber);
+ MessageList.AddChild(messageBubble);
+
+ // Add spacing between messages
+ MessageList.AddChild(new Control { MinSize = new Vector2(0, 4) });
+ }
+
+ MessageList.InvalidateMeasure();
+ MessagesScroll.InvalidateMeasure();
+
+ // Scroll to bottom after messages are added
+ if (MessageList.Parent is ScrollContainer scroll)
+ scroll.SetScrollValue(new Vector2(0, float.MaxValue));
+ }
+
+ private void UpdateMuteButton()
+ {
+ if (BellMutedIcon != null)
+ BellMutedIcon.Visible = _notificationsMuted;
+ }
+
+ public void UpdateState(NanoChatUiState state)
+ {
+ _ownNumber = state.OwnNumber;
+ _notificationsMuted = state.NotificationsMuted;
+ OwnNumberLabel.Text = $"#{state.OwnNumber:D4}";
+ UpdateMuteButton();
+
+ // Update new chat button state based on recipient limit
+ var atLimit = state.Recipients.Count >= state.MaxRecipients;
+ NewChatButton.Disabled = atLimit;
+ NewChatButton.ToolTip = atLimit
+ ? Loc.GetString("nano-chat-max-recipients")
+ : Loc.GetString("nano-chat-new-chat");
+
+ // First handle pending chat resolution if we have one
+ if (_pendingChat != null)
+ {
+ if (_pendingChat == state.CurrentChat)
+ _currentChat = _pendingChat; // Server confirmed our selection
+
+ _pendingChat = null; // Clear pending either way
+ }
+
+ // No pending chat or it was just cleared, update current directly
+ if (_pendingChat == null)
+ _currentChat = state.CurrentChat;
+
+ UpdateCurrentChat();
+ UpdateChatList(state.Recipients);
+ UpdateMessages(state.Messages);
+ }
+}
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml
new file mode 100644
index 00000000000..20095c4fce9
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml.cs
new file mode 100644
index 00000000000..0f416f41566
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml.cs
@@ -0,0 +1,87 @@
+using System.Linq;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
+
+[GenerateTypedNameReferences]
+public sealed partial class NewChatPopup : DefaultWindow
+{
+ private const int MaxInputLength = 16;
+ private const int MaxNumberLength = 4; // i hardcoded it to be 4 so suffer
+
+ public event Action? OnChatCreated;
+
+ public NewChatPopup()
+ {
+ RobustXamlLoader.Load(this);
+
+ // margins trolling
+ ContentsContainer.Margin = new Thickness(3);
+
+ // Button handlers
+ CancelButton.OnPressed += _ => Close();
+ CreateButton.OnPressed += _ => CreateChat();
+
+ // Input validation
+ NumberInput.OnTextChanged += _ => ValidateInputs();
+ NameInput.OnTextChanged += _ => ValidateInputs();
+
+ // Input validation
+ NumberInput.OnTextChanged += args =>
+ {
+ if (args.Text.Length > MaxNumberLength)
+ NumberInput.Text = args.Text[..MaxNumberLength];
+
+ // Filter to digits only
+ var newText = string.Concat(NumberInput.Text.Where(char.IsDigit));
+ if (newText != NumberInput.Text)
+ NumberInput.Text = newText;
+
+ ValidateInputs();
+ };
+
+ NameInput.OnTextChanged += args =>
+ {
+ if (args.Text.Length > MaxInputLength)
+ NameInput.Text = args.Text[..MaxInputLength];
+ ValidateInputs();
+ };
+
+ JobInput.OnTextChanged += args =>
+ {
+ if (args.Text.Length > MaxInputLength)
+ JobInput.Text = args.Text[..MaxInputLength];
+ };
+ }
+
+ private void ValidateInputs()
+ {
+ var isValid = !string.IsNullOrWhiteSpace(NumberInput.Text) &&
+ !string.IsNullOrWhiteSpace(NameInput.Text) &&
+ uint.TryParse(NumberInput.Text, out _);
+
+ CreateButton.Disabled = !isValid;
+ }
+
+ private void CreateChat()
+ {
+ if (!uint.TryParse(NumberInput.Text, out var number))
+ return;
+
+ var name = NameInput.Text.Trim();
+ var job = string.IsNullOrWhiteSpace(JobInput.Text) ? null : JobInput.Text.Trim();
+
+ OnChatCreated?.Invoke(number, name, job);
+ Close();
+ }
+
+ public void ClearInputs()
+ {
+ NumberInput.Text = string.Empty;
+ NameInput.Text = string.Empty;
+ JobInput.Text = string.Empty;
+ ValidateInputs();
+ }
+}
diff --git a/Content.Client/_CorvaxNext/NanoChat/NanoChatSystem.cs b/Content.Client/_CorvaxNext/NanoChat/NanoChatSystem.cs
new file mode 100644
index 00000000000..62f8680eaac
--- /dev/null
+++ b/Content.Client/_CorvaxNext/NanoChat/NanoChatSystem.cs
@@ -0,0 +1,5 @@
+using Content.Shared._CorvaxNext.NanoChat;
+
+namespace Content.Client._CorvaxNext.NanoChat;
+
+public sealed class NanoChatSystem : SharedNanoChatSystem;
diff --git a/Content.Server/Access/Systems/AgentIDCardSystem.cs b/Content.Server/Access/Systems/AgentIDCardSystem.cs
index a38aefce935..10f5c1fe3ce 100644
--- a/Content.Server/Access/Systems/AgentIDCardSystem.cs
+++ b/Content.Server/Access/Systems/AgentIDCardSystem.cs
@@ -9,6 +9,7 @@
using Robust.Shared.Prototypes;
using Content.Shared.Roles;
using System.Diagnostics.CodeAnalysis;
+using Content.Shared._CorvaxNext.NanoChat;
namespace Content.Server.Access.Systems
{
@@ -18,6 +19,7 @@ public sealed class AgentIDCardSystem : SharedAgentIdCardSystem
[Dependency] private readonly IdCardSystem _cardSystem = default!;
[Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly SharedNanoChatSystem _nanoChat = default!; // Corvax-Next-PDAChat
public override void Initialize()
{
@@ -28,6 +30,17 @@ public override void Initialize()
SubscribeLocalEvent(OnNameChanged);
SubscribeLocalEvent(OnJobChanged);
SubscribeLocalEvent(OnJobIconChanged);
+ SubscribeLocalEvent(OnNumberChanged); // Corvax-Next-PDAChat
+ }
+
+ // Corvax-Next-PDAChat - Add number change handler
+ private void OnNumberChanged(Entity ent, ref AgentIDCardNumberChangedMessage args)
+ {
+ if (!TryComp(ent, out var comp))
+ return;
+
+ _nanoChat.SetNumber((ent, comp), args.Number);
+ Dirty(ent, comp);
}
private void OnAfterInteract(EntityUid uid, AgentIDCardComponent component, AfterInteractEvent args)
@@ -42,6 +55,34 @@ private void OnAfterInteract(EntityUid uid, AgentIDCardComponent component, Afte
access.Tags.UnionWith(targetAccess.Tags);
var addedLength = access.Tags.Count - beforeLength;
+ // Corvax-Next-PDAChat-Start - Copy NanoChat data if available
+ if (TryComp(args.Target, out var targetNanoChat) &&
+ TryComp(uid, out var agentNanoChat))
+ {
+ // First clear existing data
+ _nanoChat.Clear((uid, agentNanoChat));
+
+ // Copy the number
+ if (_nanoChat.GetNumber((args.Target.Value, targetNanoChat)) is { } number)
+ _nanoChat.SetNumber((uid, agentNanoChat), number);
+
+ // Copy all recipients and their messages
+ foreach (var (recipientNumber, recipient) in _nanoChat.GetRecipients((args.Target.Value, targetNanoChat)))
+ {
+ _nanoChat.SetRecipient((uid, agentNanoChat), recipientNumber, recipient);
+
+ if (_nanoChat.GetMessagesForRecipient((args.Target.Value, targetNanoChat), recipientNumber) is not
+ { } messages)
+ continue;
+
+ foreach (var message in messages)
+ {
+ _nanoChat.AddMessage((uid, agentNanoChat), recipientNumber, message);
+ }
+ }
+ }
+ // Corvax-Next-PDAChat-End
+
if (addedLength == 0)
{
_popupSystem.PopupEntity(Loc.GetString("agent-id-no-new", ("card", args.Target)), args.Target.Value, args.User);
@@ -67,7 +108,17 @@ private void AfterUIOpen(EntityUid uid, AgentIDCardComponent component, AfterAct
if (!TryComp(uid, out var idCard))
return;
- var state = new AgentIDCardBoundUserInterfaceState(idCard.FullName ?? "", idCard.LocalizedJobTitle ?? "", idCard.JobIcon);
+ // Corvax-Next-PDAChat-Start - Get current number if it exists
+ uint? currentNumber = null;
+ if (TryComp(uid, out var comp))
+ currentNumber = comp.Number;
+
+ var state = new AgentIDCardBoundUserInterfaceState(
+ idCard.FullName ?? "",
+ idCard.LocalizedJobTitle ?? "",
+ idCard.JobIcon,
+ currentNumber); // Corvax-Next-PDAChat-End - Pass current number
+
_uiSystem.SetUiState(uid, AgentIDCardUiKey.Key, state);
}
diff --git a/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeComponent.cs b/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeComponent.cs
index cfa92dd67f7..e8e54c32485 100644
--- a/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeComponent.cs
+++ b/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeComponent.cs
@@ -1,4 +1,5 @@
using Content.Shared.CartridgeLoader.Cartridges;
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
using Robust.Shared.Audio;
namespace Content.Server.CartridgeLoader.Cartridges;
@@ -18,4 +19,10 @@ public sealed partial class LogProbeCartridgeComponent : Component
///
[DataField, ViewVariables(VVAccess.ReadWrite)]
public SoundSpecifier SoundScan = new SoundPathSpecifier("/Audio/Machines/scan_finish.ogg");
+
+ ///
+ /// Corvax-Next-PDAChat: The last scanned NanoChat data, if any
+ ///
+ [DataField]
+ public NanoChatData? ScannedNanoChatData;
}
diff --git a/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.cs b/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.cs
index f5ccea95900..307c8f05fa5 100644
--- a/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.cs
+++ b/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.cs
@@ -3,12 +3,13 @@
using Content.Shared.CartridgeLoader;
using Content.Shared.CartridgeLoader.Cartridges;
using Content.Shared.Popups;
+using Content.Shared._CorvaxNext.NanoChat;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Random;
namespace Content.Server.CartridgeLoader.Cartridges;
-public sealed class LogProbeCartridgeSystem : EntitySystem
+public sealed partial class LogProbeCartridgeSystem : EntitySystem // Corvax-Next-PDAChat - Made partial
{
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly CartridgeLoaderSystem? _cartridgeLoaderSystem = default!;
@@ -18,6 +19,7 @@ public sealed class LogProbeCartridgeSystem : EntitySystem
public override void Initialize()
{
base.Initialize();
+ InitializeNanoChat(); // Corvax-Next-PDAChat
SubscribeLocalEvent(OnUiReady);
SubscribeLocalEvent(AfterInteract);
}
@@ -33,6 +35,15 @@ private void AfterInteract(Entity ent, ref Cartridge
if (args.InteractEvent.Handled || !args.InteractEvent.CanReach || args.InteractEvent.Target is not { } target)
return;
+ // Corvax-Next-PDAChat-Start - Add NanoChat card scanning
+ if (TryComp(target, out var nanoChatCard))
+ {
+ ScanNanoChatCard(ent, args, target, nanoChatCard);
+ args.InteractEvent.Handled = true;
+ return;
+ }
+ // Corvax-Next-PDAChat-End
+
if (!TryComp(target, out AccessReaderComponent? accessReaderComponent))
return;
@@ -41,6 +52,7 @@ private void AfterInteract(Entity ent, ref Cartridge
_popupSystem.PopupCursor(Loc.GetString("log-probe-scan", ("device", target)), args.InteractEvent.User);
ent.Comp.PulledAccessLogs.Clear();
+ ent.Comp.ScannedNanoChatData = null; // Corvax-Next-PDAChat - Clear any previous NanoChat data
foreach (var accessRecord in accessReaderComponent.AccessLog)
{
@@ -65,7 +77,7 @@ private void OnUiReady(Entity ent, ref CartridgeUiRe
private void UpdateUiState(Entity ent, EntityUid loaderUid)
{
- var state = new LogProbeUiState(ent.Comp.PulledAccessLogs);
+ var state = new LogProbeUiState(ent.Comp.PulledAccessLogs, ent.Comp.ScannedNanoChatData); // Corvax-Next-PDAChat - NanoChat support
_cartridgeLoaderSystem?.UpdateCartridgeUiState(loaderUid, state);
}
}
diff --git a/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.NanoChat.cs b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.NanoChat.cs
new file mode 100644
index 00000000000..fdcc9d19b0d
--- /dev/null
+++ b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.NanoChat.cs
@@ -0,0 +1,82 @@
+using Content.Shared.Audio;
+using Content.Shared.CartridgeLoader;
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Content.Shared._CorvaxNext.NanoChat;
+
+namespace Content.Server.CartridgeLoader.Cartridges;
+
+public sealed partial class LogProbeCartridgeSystem
+{
+ private void InitializeNanoChat()
+ {
+ SubscribeLocalEvent(OnRecipientUpdated);
+ SubscribeLocalEvent(OnMessageReceived);
+ }
+
+ private void OnRecipientUpdated(ref NanoChatRecipientUpdatedEvent args)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var probe, out var cartridge))
+ {
+ if (probe.ScannedNanoChatData == null || GetEntity(probe.ScannedNanoChatData.Value.Card) != args.CardUid)
+ continue;
+
+ if (!TryComp(args.CardUid, out var card))
+ continue;
+
+ probe.ScannedNanoChatData = new NanoChatData(
+ new Dictionary(card.Recipients),
+ probe.ScannedNanoChatData.Value.Messages,
+ card.Number,
+ GetNetEntity(args.CardUid));
+
+ if (cartridge.LoaderUid != null)
+ UpdateUiState((uid, probe), cartridge.LoaderUid.Value);
+ }
+ }
+
+ private void OnMessageReceived(ref NanoChatMessageReceivedEvent args)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var probe, out var cartridge))
+ {
+ if (probe.ScannedNanoChatData == null || GetEntity(probe.ScannedNanoChatData.Value.Card) != args.CardUid)
+ continue;
+
+ if (!TryComp(args.CardUid, out var card))
+ continue;
+
+ probe.ScannedNanoChatData = new NanoChatData(
+ probe.ScannedNanoChatData.Value.Recipients,
+ new Dictionary>(card.Messages),
+ card.Number,
+ GetNetEntity(args.CardUid));
+
+ if (cartridge.LoaderUid != null)
+ UpdateUiState((uid, probe), cartridge.LoaderUid.Value);
+ }
+ }
+
+ private void ScanNanoChatCard(Entity ent,
+ CartridgeAfterInteractEvent args,
+ EntityUid target,
+ NanoChatCardComponent card)
+ {
+ _audioSystem.PlayEntity(ent.Comp.SoundScan,
+ args.InteractEvent.User,
+ target,
+ AudioHelpers.WithVariation(0.25f, _random));
+ _popupSystem.PopupCursor(Loc.GetString("log-probe-scan-nanochat", ("card", target)), args.InteractEvent.User);
+
+ ent.Comp.PulledAccessLogs.Clear();
+
+ ent.Comp.ScannedNanoChatData = new NanoChatData(
+ new Dictionary(card.Recipients),
+ new Dictionary>(card.Messages),
+ card.Number,
+ GetNetEntity(target)
+ );
+
+ UpdateUiState(ent, args.Loader);
+ }
+}
diff --git a/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeComponent.cs b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeComponent.cs
new file mode 100644
index 00000000000..0f4ff971c5f
--- /dev/null
+++ b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeComponent.cs
@@ -0,0 +1,26 @@
+using Content.Shared.Radio;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server._CorvaxNext.CartridgeLoader.Cartridges;
+
+[RegisterComponent, Access(typeof(NanoChatCartridgeSystem))]
+public sealed partial class NanoChatCartridgeComponent : Component
+{
+ ///
+ /// Station entity to keep track of.
+ ///
+ [DataField]
+ public EntityUid? Station;
+
+ ///
+ /// The NanoChat card to keep track of.
+ ///
+ [DataField]
+ public EntityUid? Card;
+
+ ///
+ /// The required to send or receive messages.
+ ///
+ [DataField]
+ public ProtoId RadioChannel = "Common";
+}
diff --git a/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeSystem.cs b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeSystem.cs
new file mode 100644
index 00000000000..77c3e697d53
--- /dev/null
+++ b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeSystem.cs
@@ -0,0 +1,514 @@
+using System.Linq;
+using Content.Server.Administration.Logs;
+using Content.Server.CartridgeLoader;
+using Content.Server.Power.Components;
+using Content.Server.Radio;
+using Content.Server.Radio.Components;
+using Content.Server.Station.Systems;
+using Content.Shared.Access.Components;
+using Content.Shared.CartridgeLoader;
+using Content.Shared.Database;
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Content.Shared._CorvaxNext.NanoChat;
+using Content.Shared.PDA;
+using Content.Shared.Radio.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+
+namespace Content.Server._CorvaxNext.CartridgeLoader.Cartridges;
+
+public sealed class NanoChatCartridgeSystem : EntitySystem
+{
+ [Dependency] private readonly CartridgeLoaderSystem _cartridge = default!;
+ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+ [Dependency] private readonly SharedNanoChatSystem _nanoChat = default!;
+ [Dependency] private readonly StationSystem _station = default!;
+
+ // Messages in notifications get cut off after this point
+ // no point in storing it on the comp
+ private const int NotificationMaxLength = 64;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnUiReady);
+ SubscribeLocalEvent(OnMessage);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ // Update card references for any cartridges that need it
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var nanoChat, out var cartridge))
+ {
+ if (cartridge.LoaderUid == null)
+ continue;
+
+ // Check if we need to update our card reference
+ if (!TryComp(cartridge.LoaderUid, out var pda))
+ continue;
+
+ var newCard = pda.ContainedId;
+ var currentCard = nanoChat.Card;
+
+ // If the cards match, nothing to do
+ if (newCard == currentCard)
+ continue;
+
+ // Update card reference
+ nanoChat.Card = newCard;
+
+ // Update UI state since card reference changed
+ UpdateUI((uid, nanoChat), cartridge.LoaderUid.Value);
+ }
+ }
+
+ ///
+ /// Handles incoming UI messages from the NanoChat cartridge.
+ ///
+ private void OnMessage(Entity ent, ref CartridgeMessageEvent args)
+ {
+ if (args is not NanoChatUiMessageEvent msg)
+ return;
+
+ if (!GetCardEntity(GetEntity(args.LoaderUid), out var card))
+ return;
+
+ switch (msg.Type)
+ {
+ case NanoChatUiMessageType.NewChat:
+ HandleNewChat(card, msg);
+ break;
+ case NanoChatUiMessageType.SelectChat:
+ HandleSelectChat(card, msg);
+ break;
+ case NanoChatUiMessageType.CloseChat:
+ HandleCloseChat(card);
+ break;
+ case NanoChatUiMessageType.ToggleMute:
+ HandleToggleMute(card);
+ break;
+ case NanoChatUiMessageType.DeleteChat:
+ HandleDeleteChat(card, msg);
+ break;
+ case NanoChatUiMessageType.SendMessage:
+ HandleSendMessage(ent, card, msg);
+ break;
+ }
+
+ UpdateUI(ent, GetEntity(args.LoaderUid));
+ }
+
+ ///
+ /// Gets the ID card entity associated with a PDA.
+ ///
+ /// The PDA entity ID
+ /// Output parameter containing the found card entity and component
+ /// True if a valid NanoChat card was found
+ private bool GetCardEntity(
+ EntityUid loaderUid,
+ out Entity card)
+ {
+ card = default;
+
+ // Get the PDA and check if it has an ID card
+ if (!TryComp(loaderUid, out var pda) ||
+ pda.ContainedId == null ||
+ !TryComp(pda.ContainedId, out var idCard))
+ return false;
+
+ card = (pda.ContainedId.Value, idCard);
+ return true;
+ }
+
+ ///
+ /// Handles creation of a new chat conversation.
+ ///
+ private void HandleNewChat(Entity card, NanoChatUiMessageEvent msg)
+ {
+ if (msg.RecipientNumber == null || msg.Content == null || msg.RecipientNumber == card.Comp.Number)
+ return;
+
+ // Add new recipient
+ var recipient = new NanoChatRecipient(msg.RecipientNumber.Value,
+ msg.Content,
+ msg.RecipientJob);
+
+ // Initialize or update recipient
+ _nanoChat.SetRecipient((card, card.Comp), msg.RecipientNumber.Value, recipient);
+
+ _adminLogger.Add(LogType.Action,
+ LogImpact.Low,
+ $"{ToPrettyString(msg.Actor):user} created new NanoChat conversation with #{msg.RecipientNumber:D4} ({msg.Content})");
+
+ var recipientEv = new NanoChatRecipientUpdatedEvent(card);
+ RaiseLocalEvent(ref recipientEv);
+ UpdateUIForCard(card);
+ }
+
+ ///
+ /// Handles selecting a chat conversation.
+ ///
+ private void HandleSelectChat(Entity card, NanoChatUiMessageEvent msg)
+ {
+ if (msg.RecipientNumber == null)
+ return;
+
+ _nanoChat.SetCurrentChat((card, card.Comp), msg.RecipientNumber);
+
+ // Clear unread flag when selecting chat
+ if (_nanoChat.GetRecipient((card, card.Comp), msg.RecipientNumber.Value) is { } recipient)
+ {
+ _nanoChat.SetRecipient((card, card.Comp),
+ msg.RecipientNumber.Value,
+ recipient with { HasUnread = false });
+ }
+ }
+
+ ///
+ /// Handles closing the current chat conversation.
+ ///
+ private void HandleCloseChat(Entity card)
+ {
+ _nanoChat.SetCurrentChat((card, card.Comp), null);
+ }
+
+ ///
+ /// Handles deletion of a chat conversation.
+ ///
+ private void HandleDeleteChat(Entity card, NanoChatUiMessageEvent msg)
+ {
+ if (msg.RecipientNumber == null || card.Comp.Number == null)
+ return;
+
+ // Delete chat but keep the messages
+ var deleted = _nanoChat.TryDeleteChat((card, card.Comp), msg.RecipientNumber.Value, true);
+
+ if (!deleted)
+ return;
+
+ _adminLogger.Add(LogType.Action,
+ LogImpact.Low,
+ $"{ToPrettyString(msg.Actor):user} deleted NanoChat conversation with #{msg.RecipientNumber:D4}");
+
+ UpdateUIForCard(card);
+ }
+
+ ///
+ /// Handles toggling notification mute state.
+ ///
+ private void HandleToggleMute(Entity card)
+ {
+ _nanoChat.SetNotificationsMuted((card, card.Comp), !_nanoChat.GetNotificationsMuted((card, card.Comp)));
+ UpdateUIForCard(card);
+ }
+
+ ///
+ /// Handles sending a new message in a chat conversation.
+ ///
+ private void HandleSendMessage(Entity cartridge,
+ Entity card,
+ NanoChatUiMessageEvent msg)
+ {
+ if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
+ return;
+
+ if (!EnsureRecipientExists(card, msg.RecipientNumber.Value))
+ return;
+
+ // Create and store message for sender
+ var message = new NanoChatMessage(
+ _timing.CurTime,
+ msg.Content,
+ (uint)card.Comp.Number
+ );
+
+ // Attempt delivery
+ var (deliveryFailed, recipients) = AttemptMessageDelivery(cartridge, msg.RecipientNumber.Value);
+
+ // Update delivery status
+ message = message with { DeliveryFailed = deliveryFailed };
+
+ // Store message in sender's outbox under recipient's number
+ _nanoChat.AddMessage((card, card.Comp), msg.RecipientNumber.Value, message);
+
+ // Log message attempt
+ var recipientsText = recipients.Count > 0
+ ? string.Join(", ", recipients.Select(r => ToPrettyString(r)))
+ : $"#{msg.RecipientNumber:D4}";
+
+ _adminLogger.Add(LogType.Chat,
+ LogImpact.Low,
+ $"{ToPrettyString(card):user} sent NanoChat message to {recipientsText}: {msg.Content}{(deliveryFailed ? " [DELIVERY FAILED]" : "")}");
+
+ var msgEv = new NanoChatMessageReceivedEvent(card);
+ RaiseLocalEvent(ref msgEv);
+
+ if (deliveryFailed)
+ return;
+
+ foreach (var recipient in recipients)
+ {
+ DeliverMessageToRecipient(card, recipient, message);
+ }
+ }
+
+ ///
+ /// Ensures a recipient exists in the sender's contacts.
+ ///
+ /// The card to check contacts for
+ /// The recipient's number to check
+ /// True if the recipient exists or was created successfully
+ private bool EnsureRecipientExists(Entity card, uint recipientNumber)
+ {
+ return _nanoChat.EnsureRecipientExists((card, card.Comp), recipientNumber, GetCardInfo(recipientNumber));
+ }
+
+ ///
+ /// Attempts to deliver a message to recipients.
+ ///
+ /// The sending cartridge entity
+ /// The recipient's number
+ /// Tuple containing delivery status and recipients if found.
+ private (bool failed, List> recipient) AttemptMessageDelivery(
+ Entity sender,
+ uint recipientNumber)
+ {
+ // First verify we can send from this device
+ var channel = _prototype.Index(sender.Comp.RadioChannel);
+ var sendAttemptEvent = new RadioSendAttemptEvent(channel, sender);
+ RaiseLocalEvent(ref sendAttemptEvent);
+ if (sendAttemptEvent.Cancelled)
+ return (true, new List>());
+
+ var foundRecipients = new List>();
+
+ // Find all cards with matching number
+ var cardQuery = EntityQueryEnumerator();
+ while (cardQuery.MoveNext(out var cardUid, out var card))
+ {
+ if (card.Number != recipientNumber)
+ continue;
+
+ foundRecipients.Add((cardUid, card));
+ }
+
+ if (foundRecipients.Count == 0)
+ return (true, foundRecipients);
+
+ // Now check if any of these cards can receive
+ var deliverableRecipients = new List>();
+ foreach (var recipient in foundRecipients)
+ {
+ // Find any cartridges that have this card
+ var cartridgeQuery = EntityQueryEnumerator();
+ while (cartridgeQuery.MoveNext(out var receiverUid, out var receiverCart, out _))
+ {
+ if (receiverCart.Card != recipient.Owner)
+ continue;
+
+ // Check if devices are on same station/map
+ var recipientStation = _station.GetOwningStation(receiverUid);
+ var senderStation = _station.GetOwningStation(sender);
+
+ // Both entities must be on a station
+ if (recipientStation == null || senderStation == null)
+ continue;
+
+ // Must be on same map/station unless long range allowed
+ if (!channel.LongRange && recipientStation != senderStation)
+ continue;
+
+ // Needs telecomms
+ if (!HasActiveServer(senderStation.Value) || !HasActiveServer(recipientStation.Value))
+ continue;
+
+ // Check if recipient can receive
+ var receiveAttemptEv = new RadioReceiveAttemptEvent(channel, sender, receiverUid);
+ RaiseLocalEvent(ref receiveAttemptEv);
+ if (receiveAttemptEv.Cancelled)
+ continue;
+
+ // Found valid cartridge that can receive
+ deliverableRecipients.Add(recipient);
+ break; // Only need one valid cartridge per card
+ }
+ }
+
+ return (deliverableRecipients.Count == 0, deliverableRecipients);
+ }
+
+ ///
+ /// Checks if there are any active telecomms servers on the given station
+ ///
+ private bool HasActiveServer(EntityUid station)
+ {
+ // I have no idea why this isn't public in the RadioSystem
+ var query =
+ EntityQueryEnumerator();
+
+ while (query.MoveNext(out var uid, out _, out _, out var power))
+ {
+ if (_station.GetOwningStation(uid) == station && power.Powered)
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Delivers a message to the recipient and handles associated notifications.
+ ///
+ /// The sender's card entity
+ /// The recipient's card entity
+ /// The to deliver
+ private void DeliverMessageToRecipient(Entity sender,
+ Entity recipient,
+ NanoChatMessage message)
+ {
+ var senderNumber = sender.Comp.Number;
+ if (senderNumber == null)
+ return;
+
+ // Always try to get and add sender info to recipient's contacts
+ if (!EnsureRecipientExists(recipient, senderNumber.Value))
+ return;
+
+ _nanoChat.AddMessage((recipient, recipient.Comp), senderNumber.Value, message with { DeliveryFailed = false });
+
+
+ if (_nanoChat.GetCurrentChat((recipient, recipient.Comp)) != senderNumber)
+ HandleUnreadNotification(recipient, message);
+
+ var msgEv = new NanoChatMessageReceivedEvent(recipient);
+ RaiseLocalEvent(ref msgEv);
+ UpdateUIForCard(recipient);
+ }
+
+ ///
+ /// Handles unread message notifications and updates unread status.
+ ///
+ private void HandleUnreadNotification(Entity recipient, NanoChatMessage message)
+ {
+ // Get sender name from contacts or fall back to number
+ var recipients = _nanoChat.GetRecipients((recipient, recipient.Comp));
+ var senderName = recipients.TryGetValue(message.SenderId, out var existingRecipient)
+ ? existingRecipient.Name
+ : $"#{message.SenderId:D4}";
+
+ if (!recipient.Comp.Recipients[message.SenderId].HasUnread && !recipient.Comp.NotificationsMuted)
+ {
+ var pdaQuery = EntityQueryEnumerator();
+ while (pdaQuery.MoveNext(out var pdaUid, out var pdaComp))
+ {
+ if (pdaComp.ContainedId != recipient)
+ continue;
+
+ _cartridge.SendNotification(pdaUid,
+ Loc.GetString("nano-chat-new-message-title", ("sender", senderName)),
+ Loc.GetString("nano-chat-new-message-body", ("message", TruncateMessage(message.Content))));
+ break;
+ }
+ }
+
+ // Update unread status
+ _nanoChat.SetRecipient((recipient, recipient.Comp),
+ message.SenderId,
+ existingRecipient with { HasUnread = true });
+ }
+
+ ///
+ /// Updates the UI for any PDAs containing the specified card.
+ ///
+ private void UpdateUIForCard(EntityUid cardUid)
+ {
+ // Find any PDA containing this card and update its UI
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp, out var cartridge))
+ {
+ if (comp.Card != cardUid || cartridge.LoaderUid == null)
+ continue;
+
+ UpdateUI((uid, comp), cartridge.LoaderUid.Value);
+ }
+ }
+
+ ///
+ /// Gets the for a given NanoChat number.
+ ///
+ private NanoChatRecipient? GetCardInfo(uint number)
+ {
+ // Find card with this number to get its info
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var card))
+ {
+ if (card.Number != number)
+ continue;
+
+ // Try to get job title from ID card if possible
+ string? jobTitle = null;
+ var name = "Unknown";
+ if (TryComp(uid, out var idCard))
+ {
+ jobTitle = idCard.LocalizedJobTitle;
+ name = idCard.FullName ?? name;
+ }
+
+ return new NanoChatRecipient(number, name, jobTitle);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Truncates a message to the notification maximum length.
+ ///
+ private static string TruncateMessage(string message)
+ {
+ return message.Length <= NotificationMaxLength
+ ? message
+ : message[..(NotificationMaxLength - 4)] + " [...]";
+ }
+
+ private void OnUiReady(Entity ent, ref CartridgeUiReadyEvent args)
+ {
+ _cartridge.RegisterBackgroundProgram(args.Loader, ent);
+ UpdateUI(ent, args.Loader);
+ }
+
+ private void UpdateUI(Entity ent, EntityUid loader)
+ {
+ if (_station.GetOwningStation(loader) is { } station)
+ ent.Comp.Station = station;
+
+ var recipients = new Dictionary();
+ var messages = new Dictionary>();
+ uint? currentChat = null;
+ uint ownNumber = 0;
+ var maxRecipients = 50;
+ var notificationsMuted = false;
+
+ if (ent.Comp.Card != null && TryComp(ent.Comp.Card, out var card))
+ {
+ recipients = card.Recipients;
+ messages = card.Messages;
+ currentChat = card.CurrentChat;
+ ownNumber = card.Number ?? 0;
+ maxRecipients = card.MaxRecipients;
+ notificationsMuted = card.NotificationsMuted;
+ }
+
+ var state = new NanoChatUiState(recipients,
+ messages,
+ currentChat,
+ ownNumber,
+ maxRecipients,
+ notificationsMuted);
+ _cartridge.UpdateCartridgeUiState(loader, state);
+ }
+}
diff --git a/Content.Server/_CorvaxNext/NanoChat/NanoChatSystem.cs b/Content.Server/_CorvaxNext/NanoChat/NanoChatSystem.cs
new file mode 100644
index 00000000000..4775abbf392
--- /dev/null
+++ b/Content.Server/_CorvaxNext/NanoChat/NanoChatSystem.cs
@@ -0,0 +1,130 @@
+using System.Linq;
+using Content.Server.Access.Systems;
+using Content.Server.Administration.Logs;
+using Content.Server.Kitchen.Components;
+using Content.Server.NameIdentifier;
+using Content.Shared.Database;
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Content.Shared._CorvaxNext.NanoChat;
+using Content.Shared.NameIdentifier;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server._CorvaxNext.NanoChat;
+
+///
+/// Handles NanoChat features that are specific to the server but not related to the cartridge itself.
+///
+public sealed class NanoChatSystem : SharedNanoChatSystem
+{
+ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly NameIdentifierSystem _name = default!;
+
+ private readonly ProtoId _nameIdentifierGroup = "NanoChat";
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnCardInit);
+ SubscribeLocalEvent(OnMicrowaved, after: [typeof(IdCardSystem)]);
+ }
+
+ private void OnMicrowaved(Entity ent, ref BeingMicrowavedEvent args)
+ {
+ // Skip if the entity was deleted (e.g., by ID card system burning it)
+ if (Deleted(ent))
+ return;
+
+ if (!TryComp(args.Microwave, out var micro) || micro.Broken)
+ return;
+
+ var randomPick = _random.NextFloat();
+
+ // Super lucky - erase all messages (10% chance)
+ if (randomPick <= 0.10f)
+ {
+ ent.Comp.Messages.Clear();
+ // TODO: these shouldn't be shown at the same time as the popups from IdCardSystem
+ // _popup.PopupEntity(Loc.GetString("nanochat-card-microwave-erased", ("card", ent)),
+ // ent,
+ // PopupType.Medium);
+
+ _adminLogger.Add(LogType.Action,
+ LogImpact.Medium,
+ $"{ToPrettyString(args.Microwave)} erased all messages on {ToPrettyString(ent)}");
+ }
+ else
+ {
+ // Scramble random messages for random recipients
+ ScrambleMessages(ent);
+ // _popup.PopupEntity(Loc.GetString("nanochat-card-microwave-scrambled", ("card", ent)),
+ // ent,
+ // PopupType.Medium);
+
+ _adminLogger.Add(LogType.Action,
+ LogImpact.Medium,
+ $"{ToPrettyString(args.Microwave)} scrambled messages on {ToPrettyString(ent)}");
+ }
+
+ Dirty(ent);
+ }
+
+ private void ScrambleMessages(NanoChatCardComponent component)
+ {
+ foreach (var (recipientNumber, messages) in component.Messages)
+ {
+ for (var i = 0; i < messages.Count; i++)
+ {
+ // 50% chance to scramble each message
+ if (!_random.Prob(0.5f))
+ continue;
+
+ var message = messages[i];
+ message.Content = ScrambleText(message.Content);
+ messages[i] = message;
+ }
+
+ // 25% chance to reassign the conversation to a random recipient
+ if (_random.Prob(0.25f) && component.Recipients.Count > 0)
+ {
+ var newRecipient = _random.Pick(component.Recipients.Keys.ToList());
+ if (newRecipient == recipientNumber)
+ continue;
+
+ if (!component.Messages.ContainsKey(newRecipient))
+ component.Messages[newRecipient] = new List();
+
+ component.Messages[newRecipient].AddRange(messages);
+ component.Messages[recipientNumber].Clear();
+ }
+ }
+ }
+
+ private string ScrambleText(string text)
+ {
+ var chars = text.ToCharArray();
+ var n = chars.Length;
+
+ // Fisher-Yates shuffle of characters
+ while (n > 1)
+ {
+ n--;
+ var k = _random.Next(n + 1);
+ (chars[k], chars[n]) = (chars[n], chars[k]);
+ }
+
+ return new string(chars);
+ }
+
+ private void OnCardInit(Entity ent, ref MapInitEvent args)
+ {
+ if (ent.Comp.Number != null)
+ return;
+
+ // Assign a random number
+ _name.GenerateUniqueName(ent, _nameIdentifierGroup, out var number);
+ ent.Comp.Number = (uint)number;
+ Dirty(ent);
+ }
+}
diff --git a/Content.Shared/Access/SharedAgentIDCardSystem.cs b/Content.Shared/Access/SharedAgentIDCardSystem.cs
index aefd413de8b..12c40982ef4 100644
--- a/Content.Shared/Access/SharedAgentIDCardSystem.cs
+++ b/Content.Shared/Access/SharedAgentIDCardSystem.cs
@@ -28,12 +28,26 @@ public sealed class AgentIDCardBoundUserInterfaceState : BoundUserInterfaceState
public string CurrentName { get; }
public string CurrentJob { get; }
public string CurrentJobIconId { get; }
+ public uint? CurrentNumber { get; } // Corvax-Next-PDAChat
- public AgentIDCardBoundUserInterfaceState(string currentName, string currentJob, string currentJobIconId)
+ public AgentIDCardBoundUserInterfaceState(string currentName, string currentJob, string currentJobIconId, uint? currentNumber = null) // Corvax-Next-PDAChat - Added currentNumber
{
CurrentName = currentName;
CurrentJob = currentJob;
CurrentJobIconId = currentJobIconId;
+ CurrentNumber = currentNumber; // Corvax-Next-PDAChat
+ }
+ }
+
+ // Corvax-Next-PDAChat - Add number change message
+ [Serializable, NetSerializable]
+ public sealed class AgentIDCardNumberChangedMessage : BoundUserInterfaceMessage
+ {
+ public uint Number { get; }
+
+ public AgentIDCardNumberChangedMessage(uint number)
+ {
+ Number = number;
}
}
diff --git a/Content.Shared/CartridgeLoader/Cartridges/LogProbeUiState.cs b/Content.Shared/CartridgeLoader/Cartridges/LogProbeUiState.cs
index 9dc507b7e51..fbe9ee3fc4b 100644
--- a/Content.Shared/CartridgeLoader/Cartridges/LogProbeUiState.cs
+++ b/Content.Shared/CartridgeLoader/Cartridges/LogProbeUiState.cs
@@ -1,4 +1,5 @@
-using Robust.Shared.Serialization;
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Robust.Shared.Serialization;
namespace Content.Shared.CartridgeLoader.Cartridges;
@@ -10,9 +11,15 @@ public sealed class LogProbeUiState : BoundUserInterfaceState
///
public List PulledLogs;
- public LogProbeUiState(List pulledLogs)
+ ///
+ /// Corvax-Next-PDAChat: The NanoChat data if a card was scanned, null otherwise
+ ///
+ public NanoChatData? NanoChatData { get; }
+
+ public LogProbeUiState(List pulledLogs, NanoChatData? nanoChatData = null) // Corvax-Next-PDAChat - NanoChat support
{
PulledLogs = pulledLogs;
+ NanoChatData = nanoChatData; // Corvax-Next-PDAChat
}
}
diff --git a/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiMessageEvent.cs b/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiMessageEvent.cs
new file mode 100644
index 00000000000..c426ca63150
--- /dev/null
+++ b/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiMessageEvent.cs
@@ -0,0 +1,166 @@
+using Content.Shared.CartridgeLoader;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+
+[Serializable, NetSerializable]
+public sealed class NanoChatUiMessageEvent : CartridgeMessageEvent
+{
+ ///
+ /// The type of UI message being sent.
+ ///
+ public readonly NanoChatUiMessageType Type;
+
+ ///
+ /// The recipient's NanoChat number, if applicable.
+ ///
+ public readonly uint? RecipientNumber;
+
+ ///
+ /// The content of the message or name for new chats.
+ ///
+ public readonly string? Content;
+
+ ///
+ /// The recipient's job title when creating a new chat.
+ ///
+ public readonly string? RecipientJob;
+
+ ///
+ /// Creates a new NanoChat UI message event.
+ ///
+ /// The type of message being sent
+ /// Optional recipient number for the message
+ /// Optional content of the message
+ /// Optional job title for new chat creation
+ public NanoChatUiMessageEvent(NanoChatUiMessageType type,
+ uint? recipientNumber = null,
+ string? content = null,
+ string? recipientJob = null)
+ {
+ Type = type;
+ RecipientNumber = recipientNumber;
+ Content = content;
+ RecipientJob = recipientJob;
+ }
+}
+
+[Serializable, NetSerializable]
+public enum NanoChatUiMessageType : byte
+{
+ NewChat,
+ SelectChat,
+ CloseChat,
+ SendMessage,
+ DeleteChat,
+ ToggleMute,
+}
+
+// putting this here because i can
+[Serializable, NetSerializable, DataRecord]
+public struct NanoChatRecipient
+{
+ ///
+ /// The recipient's unique NanoChat number.
+ ///
+ public uint Number;
+
+ ///
+ /// The recipient's display name, typically from their ID card.
+ ///
+ public string Name;
+
+ ///
+ /// The recipient's job title, if available.
+ ///
+ public string? JobTitle;
+
+ ///
+ /// Whether this recipient has unread messages.
+ ///
+ public bool HasUnread;
+
+ ///
+ /// Creates a new NanoChat recipient.
+ ///
+ /// The recipient's NanoChat number
+ /// The recipient's display name
+ /// Optional job title for the recipient
+ /// Whether there are unread messages from this recipient
+ public NanoChatRecipient(uint number, string name, string? jobTitle = null, bool hasUnread = false)
+ {
+ Number = number;
+ Name = name;
+ JobTitle = jobTitle;
+ HasUnread = hasUnread;
+ }
+}
+
+[Serializable, NetSerializable, DataRecord]
+public struct NanoChatMessage
+{
+ ///
+ /// When the message was sent.
+ ///
+ public TimeSpan Timestamp;
+
+ ///
+ /// The content of the message.
+ ///
+ public string Content;
+
+ ///
+ /// The NanoChat number of the sender.
+ ///
+ public uint SenderId;
+
+ ///
+ /// Whether the message failed to deliver to the recipient.
+ /// This can happen if the recipient is out of range or if there's no active telecomms server.
+ ///
+ public bool DeliveryFailed;
+
+ ///
+ /// Creates a new NanoChat message.
+ ///
+ /// When the message was sent
+ /// The content of the message
+ /// The sender's NanoChat number
+ /// Whether delivery to the recipient failed
+ public NanoChatMessage(TimeSpan timestamp, string content, uint senderId, bool deliveryFailed = false)
+ {
+ Timestamp = timestamp;
+ Content = content;
+ SenderId = senderId;
+ DeliveryFailed = deliveryFailed;
+ }
+}
+
+///
+/// NanoChat log data struct
+///
+/// Used by the LogProbe
+[Serializable, NetSerializable, DataRecord]
+public readonly struct NanoChatData(
+ Dictionary recipients,
+ Dictionary> messages,
+ uint? cardNumber,
+ NetEntity card)
+{
+ public Dictionary Recipients { get; } = recipients;
+ public Dictionary> Messages { get; } = messages;
+ public uint? CardNumber { get; } = cardNumber;
+ public NetEntity Card { get; } = card;
+}
+
+///
+/// Raised on the NanoChat card whenever a recipient gets added
+///
+[ByRefEvent]
+public readonly record struct NanoChatRecipientUpdatedEvent(EntityUid CardUid);
+
+///
+/// Raised on the NanoChat card whenever it receives or tries sending a messsage
+///
+[ByRefEvent]
+public readonly record struct NanoChatMessageReceivedEvent(EntityUid CardUid);
diff --git a/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiState.cs b/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiState.cs
new file mode 100644
index 00000000000..bed210682ae
--- /dev/null
+++ b/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiState.cs
@@ -0,0 +1,30 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+
+[Serializable, NetSerializable]
+public sealed class NanoChatUiState : BoundUserInterfaceState
+{
+ public readonly Dictionary Recipients = new();
+ public readonly Dictionary> Messages = new();
+ public readonly uint? CurrentChat;
+ public readonly uint OwnNumber;
+ public readonly int MaxRecipients;
+ public readonly bool NotificationsMuted;
+
+ public NanoChatUiState(
+ Dictionary recipients,
+ Dictionary> messages,
+ uint? currentChat,
+ uint ownNumber,
+ int maxRecipients,
+ bool notificationsMuted)
+ {
+ Recipients = recipients;
+ Messages = messages;
+ CurrentChat = currentChat;
+ OwnNumber = ownNumber;
+ MaxRecipients = maxRecipients;
+ NotificationsMuted = notificationsMuted;
+ }
+}
diff --git a/Content.Shared/_CorvaxNext/NanoChat/NanoChatCardComponent.cs b/Content.Shared/_CorvaxNext/NanoChat/NanoChatCardComponent.cs
new file mode 100644
index 00000000000..7830b902a09
--- /dev/null
+++ b/Content.Shared/_CorvaxNext/NanoChat/NanoChatCardComponent.cs
@@ -0,0 +1,52 @@
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared._CorvaxNext.NanoChat;
+
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedNanoChatSystem))]
+[AutoGenerateComponentPause, AutoGenerateComponentState]
+public sealed partial class NanoChatCardComponent : Component
+{
+ ///
+ /// The number assigned to this card.
+ ///
+ [DataField, AutoNetworkedField]
+ public uint? Number;
+
+ ///
+ /// All chat recipients stored on this card.
+ ///
+ [DataField]
+ public Dictionary Recipients = new();
+
+ ///
+ /// All messages stored on this card, keyed by recipient number.
+ ///
+ [DataField]
+ public Dictionary> Messages = new();
+
+ ///
+ /// The currently selected chat recipient number.
+ ///
+ [DataField]
+ public uint? CurrentChat;
+
+ ///
+ /// The maximum amount of recipients this card supports.
+ ///
+ [DataField]
+ public int MaxRecipients = 50;
+
+ ///
+ /// Last time a message was sent, for rate limiting.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField]
+ public TimeSpan LastMessageTime; // TODO: actually use this, compare against actor and not the card
+
+ ///
+ /// Whether to send notifications.
+ ///
+ [DataField]
+ public bool NotificationsMuted;
+}
diff --git a/Content.Shared/_CorvaxNext/NanoChat/SharedNanoChatSystem.cs b/Content.Shared/_CorvaxNext/NanoChat/SharedNanoChatSystem.cs
new file mode 100644
index 00000000000..f40b84cbb92
--- /dev/null
+++ b/Content.Shared/_CorvaxNext/NanoChat/SharedNanoChatSystem.cs
@@ -0,0 +1,273 @@
+using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+using Content.Shared.Examine;
+using Robust.Shared.Timing;
+
+namespace Content.Shared._CorvaxNext.NanoChat;
+
+///
+/// Base system for NanoChat functionality shared between client and server.
+///
+public abstract class SharedNanoChatSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnExamined);
+ }
+
+ private void OnExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ if (ent.Comp.Number == null)
+ {
+ args.PushMarkup(Loc.GetString("nanochat-card-examine-no-number"));
+ return;
+ }
+
+ args.PushMarkup(Loc.GetString("nanochat-card-examine-number", ("number", $"{ent.Comp.Number:D4}")));
+ }
+
+ #region Public API Methods
+
+ ///
+ /// Gets the NanoChat number for a card.
+ ///
+ public uint? GetNumber(Entity card)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return null;
+
+ return card.Comp.Number;
+ }
+
+ ///
+ /// Sets the NanoChat number for a card.
+ ///
+ public void SetNumber(Entity card, uint number)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return;
+
+ card.Comp.Number = number;
+ Dirty(card);
+ }
+
+ ///
+ /// Gets the recipients dictionary from a card.
+ ///
+ public IReadOnlyDictionary GetRecipients(Entity card)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return new Dictionary();
+
+ return card.Comp.Recipients;
+ }
+
+ ///
+ /// Gets the messages dictionary from a card.
+ ///
+ public IReadOnlyDictionary> GetMessages(Entity card)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return new Dictionary>();
+
+ return card.Comp.Messages;
+ }
+
+ ///
+ /// Sets a specific recipient in the card.
+ ///
+ public void SetRecipient(Entity card, uint number, NanoChatRecipient recipient)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return;
+
+ card.Comp.Recipients[number] = recipient;
+ Dirty(card);
+ }
+
+ ///
+ /// Gets a specific recipient from the card.
+ ///
+ public NanoChatRecipient? GetRecipient(Entity card, uint number)
+ {
+ if (!Resolve(card, ref card.Comp) || !card.Comp.Recipients.TryGetValue(number, out var recipient))
+ return null;
+
+ return recipient;
+ }
+
+ ///
+ /// Gets all messages for a specific recipient.
+ ///
+ public List? GetMessagesForRecipient(Entity card, uint recipientNumber)
+ {
+ if (!Resolve(card, ref card.Comp) || !card.Comp.Messages.TryGetValue(recipientNumber, out var messages))
+ return null;
+
+ return new List(messages);
+ }
+
+ ///
+ /// Adds a message to a recipient's conversation.
+ ///
+ public void AddMessage(Entity card, uint recipientNumber, NanoChatMessage message)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return;
+
+ if (!card.Comp.Messages.TryGetValue(recipientNumber, out var messages))
+ {
+ messages = new List();
+ card.Comp.Messages[recipientNumber] = messages;
+ }
+
+ messages.Add(message);
+ card.Comp.LastMessageTime = _timing.CurTime;
+ Dirty(card);
+ }
+
+ ///
+ /// Gets the currently selected chat recipient.
+ ///
+ public uint? GetCurrentChat(Entity card)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return null;
+
+ return card.Comp.CurrentChat;
+ }
+
+ ///
+ /// Sets the currently selected chat recipient.
+ ///
+ public void SetCurrentChat(Entity card, uint? recipient)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return;
+
+ card.Comp.CurrentChat = recipient;
+ Dirty(card);
+ }
+
+ ///
+ /// Gets whether notifications are muted.
+ ///
+ public bool GetNotificationsMuted(Entity card)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return false;
+
+ return card.Comp.NotificationsMuted;
+ }
+
+ ///
+ /// Sets whether notifications are muted.
+ ///
+ public void SetNotificationsMuted(Entity card, bool muted)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return;
+
+ card.Comp.NotificationsMuted = muted;
+ Dirty(card);
+ }
+
+ ///
+ /// Gets the time of the last message.
+ ///
+ public TimeSpan? GetLastMessageTime(Entity card)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return null;
+
+ return card.Comp.LastMessageTime;
+ }
+
+ ///
+ /// Gets if there are unread messages from a recipient.
+ ///
+ public bool HasUnreadMessages(Entity card, uint recipientNumber)
+ {
+ if (!Resolve(card, ref card.Comp) || !card.Comp.Recipients.TryGetValue(recipientNumber, out var recipient))
+ return false;
+
+ return recipient.HasUnread;
+ }
+
+ ///
+ /// Clears all messages and recipients from the card.
+ ///
+ public void Clear(Entity card)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return;
+
+ card.Comp.Messages.Clear();
+ card.Comp.Recipients.Clear();
+ card.Comp.CurrentChat = null;
+ Dirty(card);
+ }
+
+ ///
+ /// Deletes a chat conversation with a recipient from the card.
+ /// Optionally keeps message history while removing from active chats.
+ ///
+ /// True if the chat was deleted successfully
+ public bool TryDeleteChat(Entity card, uint recipientNumber, bool keepMessages = false)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return false;
+
+ // Remove from recipients list
+ var removed = card.Comp.Recipients.Remove(recipientNumber);
+
+ // Clear messages if requested
+ if (!keepMessages)
+ card.Comp.Messages.Remove(recipientNumber);
+
+ // Clear current chat if we just deleted it
+ if (card.Comp.CurrentChat == recipientNumber)
+ card.Comp.CurrentChat = null;
+
+ if (removed)
+ Dirty(card);
+
+ return removed;
+ }
+
+ ///
+ /// Ensures a recipient exists in the card's contacts and message lists.
+ /// If the recipient doesn't exist, they will be added with the provided info.
+ ///
+ /// True if the recipient was added or already existed
+ public bool EnsureRecipientExists(Entity card,
+ uint recipientNumber,
+ NanoChatRecipient? recipientInfo = null)
+ {
+ if (!Resolve(card, ref card.Comp))
+ return false;
+
+ if (!card.Comp.Recipients.ContainsKey(recipientNumber))
+ {
+ // Only add if we have recipient info
+ if (recipientInfo == null)
+ return false;
+
+ card.Comp.Recipients[recipientNumber] = recipientInfo.Value;
+ }
+
+ // Ensure message list exists for this recipient
+ if (!card.Comp.Messages.ContainsKey(recipientNumber))
+ card.Comp.Messages[recipientNumber] = new List();
+
+ Dirty(card);
+ return true;
+ }
+
+ #endregion
+}
diff --git a/NanoChat.patch b/NanoChat.patch
new file mode 100644
index 00000000000..16d21a1603c
--- /dev/null
+++ b/NanoChat.patch
@@ -0,0 +1,3664 @@
+From d8413f723589550fdfded50a44fcda7e87e1fcf2 Mon Sep 17 00:00:00 2001
+From: Vonsant
+Date: Thu, 12 Dec 2024 14:04:23 +0300
+Subject: [PATCH 1/3] NanoChat
+
+---
+ .../UI/AgentIDCardBoundUserInterface.cs | 9 +
+ .../Access/UI/AgentIDCardWindow.xaml | 4 +
+ .../Access/UI/AgentIDCardWindow.xaml.cs | 37 ++
+ .../CartridgeLoader/Cartridges/LogProbeUi.cs | 2 +-
+ .../Cartridges/LogProbeUiFragment.xaml | 26 +-
+ .../Cartridges/LogProbeUiFragment.xaml.cs | 109 +++-
+ .../Cartridges/NanoChatEntry.xaml | 48 ++
+ .../Cartridges/NanoChatEntry.xaml.cs | 39 ++
+ .../Cartridges/NanoChatLogEntry.xaml | 21 +
+ .../Cartridges/NanoChatLogEntry.xaml.cs | 17 +
+ .../Cartridges/NanoChatMessageBubble.xaml | 55 ++
+ .../Cartridges/NanoChatMessageBubble.xaml.cs | 62 +++
+ .../CartridgeLoader/Cartridges/NanoChatUi.cs | 43 ++
+ .../Cartridges/NanoChatUiFragment.xaml | 167 ++++++
+ .../Cartridges/NanoChatUiFragment.xaml.cs | 254 +++++++++
+ .../Cartridges/NewChatPopup.xaml | 52 ++
+ .../Cartridges/NewChatPopup.xaml.cs | 87 +++
+ .../_CorvaxNext/NanoChat/NanoChatSystem.cs | 5 +
+ .../Access/Systems/AgentIDCardSystem.cs | 53 +-
+ .../Cartridges/LogProbeCartridgeComponent.cs | 7 +
+ .../Cartridges/LogProbeCartridgeSystem.cs | 16 +-
+ .../LogProbeCartridgeSystem.NanoChat.cs | 82 +++
+ .../Cartridges/NanoChatCartridgeComponent.cs | 26 +
+ .../Cartridges/NanoChatCartridgeSystem.cs | 514 ++++++++++++++++++
+ .../_CorvaxNext/NanoChat/NanoChatSystem.cs | 130 +++++
+ .../Access/SharedAgentIDCardSystem.cs | 16 +-
+ .../Cartridges/LogProbeUiState.cs | 11 +-
+ .../Cartridges/NanoChatUiMessageEvent.cs | 166 ++++++
+ .../Cartridges/NanoChatUiState.cs | 30 +
+ .../NanoChat/NanoChatCardComponent.cs | 52 ++
+ .../NanoChat/SharedNanoChatSystem.cs | 273 ++++++++++
+ .../components/agent-id-card-component.ftl | 1 +
+ .../_corvaxnext/cartridge-loader/nanochat.ftl | 36 ++
+ .../components/nanochat-card-component.ftl | 5 +
+ .../components/agent-id-card-component.ftl | 1 +
+ .../_corvaxnext/cartridge-loader/nanochat.ftl | 36 ++
+ .../components/nanochat-card-component.ftl | 5 +
+ .../Entities/Objects/Devices/pda.yml | 11 +
+ .../Objects/Misc/identification_cards.yml | 3 +
+ .../Entities/Objects/Devices/cartridges.yml | 23 +-
+ .../_CorvaxNext/name_identifier_groups.yml | 4 +
+ .../Interface/VerbIcons/ATTRIBUTION.txt | 2 +
+ .../_CorvaxNext/Interface/VerbIcons/bell.svg | 5 +
+ .../Interface/VerbIcons/bell.svg.png | Bin 0 -> 720 bytes
+ .../Interface/VerbIcons/bell_muted.png | Bin 0 -> 355 bytes
+ .../Misc/program_icons.rsi/meta.json | 14 +
+ .../Misc/program_icons.rsi/nanochat.png | Bin 0 -> 725 bytes
+ .../Devices/cartridge.rsi/cart-chat.png | Bin 0 -> 419 bytes
+ .../Objects/Devices/cartridge.rsi/meta.json | 5 +-
+ 49 files changed, 2549 insertions(+), 15 deletions(-)
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml.cs
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml.cs
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml.cs
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUi.cs
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml.cs
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml
+ create mode 100644 Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml.cs
+ create mode 100644 Content.Client/_CorvaxNext/NanoChat/NanoChatSystem.cs
+ create mode 100644 Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.NanoChat.cs
+ create mode 100644 Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeComponent.cs
+ create mode 100644 Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeSystem.cs
+ create mode 100644 Content.Server/_CorvaxNext/NanoChat/NanoChatSystem.cs
+ create mode 100644 Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiMessageEvent.cs
+ create mode 100644 Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiState.cs
+ create mode 100644 Content.Shared/_CorvaxNext/NanoChat/NanoChatCardComponent.cs
+ create mode 100644 Content.Shared/_CorvaxNext/NanoChat/SharedNanoChatSystem.cs
+ create mode 100644 Resources/Locale/en-US/_corvaxnext/access/components/agent-id-card-component.ftl
+ create mode 100644 Resources/Locale/en-US/_corvaxnext/cartridge-loader/nanochat.ftl
+ create mode 100644 Resources/Locale/en-US/_corvaxnext/nanochat/components/nanochat-card-component.ftl
+ create mode 100644 Resources/Locale/ru-RU/_corvaxnext/access/components/agent-id-card-component.ftl
+ create mode 100644 Resources/Locale/ru-RU/_corvaxnext/cartridge-loader/nanochat.ftl
+ create mode 100644 Resources/Locale/ru-RU/_corvaxnext/nanochat/components/nanochat-card-component.ftl
+ create mode 100644 Resources/Prototypes/_CorvaxNext/name_identifier_groups.yml
+ create mode 100644 Resources/Textures/_CorvaxNext/Interface/VerbIcons/ATTRIBUTION.txt
+ create mode 100644 Resources/Textures/_CorvaxNext/Interface/VerbIcons/bell.svg
+ create mode 100644 Resources/Textures/_CorvaxNext/Interface/VerbIcons/bell.svg.png
+ create mode 100644 Resources/Textures/_CorvaxNext/Interface/VerbIcons/bell_muted.png
+ create mode 100644 Resources/Textures/_CorvaxNext/Misc/program_icons.rsi/meta.json
+ create mode 100644 Resources/Textures/_CorvaxNext/Misc/program_icons.rsi/nanochat.png
+ create mode 100644 Resources/Textures/_CorvaxNext/Objects/Devices/cartridge.rsi/cart-chat.png
+
+diff --git a/Content.Client/Access/UI/AgentIDCardBoundUserInterface.cs b/Content.Client/Access/UI/AgentIDCardBoundUserInterface.cs
+index 050756fcd14..24e9bc7a01d 100644
+--- a/Content.Client/Access/UI/AgentIDCardBoundUserInterface.cs
++++ b/Content.Client/Access/UI/AgentIDCardBoundUserInterface.cs
+@@ -26,8 +26,16 @@ protected override void Open()
+ _window.OnNameChanged += OnNameChanged;
+ _window.OnJobChanged += OnJobChanged;
+ _window.OnJobIconChanged += OnJobIconChanged;
++ _window.OnNumberChanged += OnNumberChanged; // Corvax-Next-PDAChat
+ }
+
++ // Corvax-Next-PDAChat-Start
++ private void OnNumberChanged(uint newNumber)
++ {
++ SendMessage(new AgentIDCardNumberChangedMessage(newNumber));
++ }
++ // Corvax-Next-PDAChat-End
++
+ private void OnNameChanged(string newName)
+ {
+ SendMessage(new AgentIDCardNameChangedMessage(newName));
+@@ -56,6 +64,7 @@ protected override void UpdateState(BoundUserInterfaceState state)
+ _window.SetCurrentName(cast.CurrentName);
+ _window.SetCurrentJob(cast.CurrentJob);
+ _window.SetAllowedIcons(cast.CurrentJobIconId);
++ _window.SetCurrentNumber(cast.CurrentNumber); // Corvax-Next-PDAChat
+ }
+ }
+ }
+diff --git a/Content.Client/Access/UI/AgentIDCardWindow.xaml b/Content.Client/Access/UI/AgentIDCardWindow.xaml
+index 7d091e4e165..a61ed2a5ae2 100644
+--- a/Content.Client/Access/UI/AgentIDCardWindow.xaml
++++ b/Content.Client/Access/UI/AgentIDCardWindow.xaml
+@@ -6,6 +6,10 @@
+
+
+
++
++
++
++
+
+
+
+diff --git a/Content.Client/Access/UI/AgentIDCardWindow.xaml.cs b/Content.Client/Access/UI/AgentIDCardWindow.xaml.cs
+index 320bb88a67e..14df0ff7ef8 100644
+--- a/Content.Client/Access/UI/AgentIDCardWindow.xaml.cs
++++ b/Content.Client/Access/UI/AgentIDCardWindow.xaml.cs
+@@ -20,9 +20,13 @@ public sealed partial class AgentIDCardWindow : DefaultWindow
+ private readonly SpriteSystem _spriteSystem;
+
+ private const int JobIconColumnCount = 10;
++
++ private const int MaxNumberLength = 4; // Corvax-Next-PDAChat - Same as NewChatPopup
+
+ public event Action? OnNameChanged;
+ public event Action? OnJobChanged;
++
++ public event Action? OnNumberChanged; // Corvax-Next-PDAChat - Add event for number changes
+
+ public event Action>? OnJobIconChanged;
+
+@@ -35,9 +39,42 @@ public AgentIDCardWindow()
+ NameLineEdit.OnTextEntered += e => OnNameChanged?.Invoke(e.Text);
+ NameLineEdit.OnFocusExit += e => OnNameChanged?.Invoke(e.Text);
+
++ // Corvax-Next-PDAChat-Start
+ JobLineEdit.OnTextEntered += e => OnJobChanged?.Invoke(e.Text);
+ JobLineEdit.OnFocusExit += e => OnJobChanged?.Invoke(e.Text);
++
++ // Corvax-Next-PDAChat - Add handlers for number changes
++ NumberLineEdit.OnTextEntered += OnNumberEntered;
++ NumberLineEdit.OnFocusExit += OnNumberEntered;
++
++ // Corvax-Next-PDAChat - Filter to only allow digits
++ NumberLineEdit.OnTextChanged += args =>
++ {
++ if (args.Text.Length > MaxNumberLength)
++ {
++ NumberLineEdit.Text = args.Text[..MaxNumberLength];
++ }
++
++ // Filter to digits only
++ var newText = string.Concat(args.Text.Where(char.IsDigit));
++ if (newText != args.Text)
++ NumberLineEdit.Text = newText;
++ };
++ }
++
++ // Corvax-Next-PDAChat - Add number validation and event
++ private void OnNumberEntered(LineEdit.LineEditEventArgs args)
++ {
++ if (uint.TryParse(args.Text, out var number) && number > 0)
++ OnNumberChanged?.Invoke(number);
++ }
++
++ // Corvax-Next-PDAChat - Add setter for current number
++ public void SetCurrentNumber(uint? number)
++ {
++ NumberLineEdit.Text = number?.ToString("D4") ?? "";
+ }
++ // Corvax-Next-PDAChat-End
+
+ public void SetAllowedIcons(string currentJobIconId)
+ {
+diff --git a/Content.Client/CartridgeLoader/Cartridges/LogProbeUi.cs b/Content.Client/CartridgeLoader/Cartridges/LogProbeUi.cs
+index d28d3228c94..7ece0abcbd9 100644
+--- a/Content.Client/CartridgeLoader/Cartridges/LogProbeUi.cs
++++ b/Content.Client/CartridgeLoader/Cartridges/LogProbeUi.cs
+@@ -23,6 +23,6 @@ public override void UpdateState(BoundUserInterfaceState state)
+ if (state is not LogProbeUiState logProbeUiState)
+ return;
+
+- _fragment?.UpdateState(logProbeUiState.PulledLogs);
++ _fragment?.UpdateState(logProbeUiState); // Corvax-Next-PDAChat - just take the state
+ }
+ }
+diff --git a/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml b/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml
+index d12fb55cdce..7c571e08f9b 100644
+--- a/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml
++++ b/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml
+@@ -9,10 +9,28 @@
+ BorderColor="#5a5a5a"
+ BorderThickness="0 0 0 1"/>
+
+-
+-
+-
+-
++
++
++
++
++
++
++
++
++
++
++
++
++
++
+
+
+
+diff --git a/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml.cs b/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml.cs
+index b22e0bc1964..a16c92a066c 100644
+--- a/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml.cs
++++ b/Content.Client/CartridgeLoader/Cartridges/LogProbeUiFragment.xaml.cs
+@@ -1,4 +1,7 @@
+-using Content.Shared.CartridgeLoader.Cartridges;
++using System.Linq;
++using Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
++using Content.Shared.CartridgeLoader.Cartridges;
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+ using Robust.Client.AutoGenerated;
+ using Robust.Client.UserInterface.Controls;
+ using Robust.Client.UserInterface.XAML;
+@@ -13,10 +16,112 @@ public LogProbeUiFragment()
+ RobustXamlLoader.Load(this);
+ }
+
+- public void UpdateState(List logs)
++ // Corvax-Next-PDAChat-Start - Update to handle both types of data
++ public void UpdateState(LogProbeUiState state)
+ {
+ ProbedDeviceContainer.RemoveAllChildren();
+
++ if (state.NanoChatData != null)
++ {
++ SetupNanoChatView(state.NanoChatData.Value);
++ DisplayNanoChatData(state.NanoChatData.Value);
++ }
++ else
++ {
++ SetupAccessLogView();
++ if (state.PulledLogs.Count > 0)
++ DisplayAccessLogs(state.PulledLogs);
++ }
++ }
++
++ private void SetupNanoChatView(NanoChatData data)
++ {
++ TitleLabel.Text = Loc.GetString("log-probe-header-nanochat");
++ ContentLabel.Text = Loc.GetString("log-probe-label-message");
++
++ // Show card info if available
++ var cardInfo = new List();
++ if (data.CardNumber != null)
++ cardInfo.Add(Loc.GetString("log-probe-card-number", ("number", $"#{data.CardNumber:D4}")));
++
++ // Add recipient count
++ cardInfo.Add(Loc.GetString("log-probe-recipients", ("count", data.Recipients.Count)));
++
++ CardNumberLabel.Text = string.Join(" | ", cardInfo);
++ CardNumberLabel.Visible = true;
++ }
++
++ private void SetupAccessLogView()
++ {
++ TitleLabel.Text = Loc.GetString("log-probe-header-access");
++ ContentLabel.Text = Loc.GetString("log-probe-label-accessor");
++ CardNumberLabel.Visible = false;
++ }
++
++ private void DisplayNanoChatData(NanoChatData data)
++ {
++ // First add a recipient list entry
++ var recipientsList = Loc.GetString("log-probe-recipient-list") + "\n" + string.Join("\n",
++ data.Recipients.Values
++ .OrderBy(r => r.Name)
++ .Select(r => $" {r.Name}" +
++ (string.IsNullOrEmpty(r.JobTitle) ? "" : $" ({r.JobTitle})") +
++ $" | #{r.Number:D4}"));
++
++ var recipientsEntry = new LogProbeUiEntry(0, "---", recipientsList);
++ ProbedDeviceContainer.AddChild(recipientsEntry);
++
++ var count = 1;
++ foreach (var (partnerId, messages) in data.Messages)
++ {
++ // Show only successfully delivered incoming messages
++ var incomingMessages = messages
++ .Where(msg => msg.SenderId == partnerId && !msg.DeliveryFailed)
++ .OrderByDescending(msg => msg.Timestamp);
++
++ foreach (var msg in incomingMessages)
++ {
++ var messageText = Loc.GetString("log-probe-message-format",
++ ("sender", $"#{msg.SenderId:D4}"),
++ ("recipient", $"#{data.CardNumber:D4}"),
++ ("content", msg.Content));
++
++ var entry = new NanoChatLogEntry(
++ count,
++ TimeSpan.FromSeconds(Math.Truncate(msg.Timestamp.TotalSeconds)).ToString(),
++ messageText);
++
++ ProbedDeviceContainer.AddChild(entry);
++ count++;
++ }
++
++ // Show only successfully delivered outgoing messages
++ var outgoingMessages = messages
++ .Where(msg => msg.SenderId == data.CardNumber && !msg.DeliveryFailed)
++ .OrderByDescending(msg => msg.Timestamp);
++
++ foreach (var msg in outgoingMessages)
++ {
++ var messageText = Loc.GetString("log-probe-message-format",
++ ("sender", $"#{msg.SenderId:D4}"),
++ ("recipient", $"#{partnerId:D4}"),
++ ("content", msg.Content));
++
++ var entry = new NanoChatLogEntry(
++ count,
++ TimeSpan.FromSeconds(Math.Truncate(msg.Timestamp.TotalSeconds)).ToString(),
++ messageText);
++
++ ProbedDeviceContainer.AddChild(entry);
++ count++;
++ }
++ }
++ }
++ // Corvax-Next-PDAChat-End
++
++ // Corvax-Next-PDAChat - Handle this in a separate method
++ private void DisplayAccessLogs(List logs)
++ {
+ //Reverse the list so the oldest entries appear at the bottom
+ logs.Reverse();
+
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml
+new file mode 100644
+index 00000000000..ac610343682
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml
+@@ -0,0 +1,48 @@
++
++
++
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml.cs
+new file mode 100644
+index 00000000000..a055c04879d
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatEntry.xaml.cs
+@@ -0,0 +1,39 @@
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Robust.Client.AutoGenerated;
++using Robust.Client.UserInterface.Controls;
++using Robust.Client.UserInterface.XAML;
++
++namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
++
++[GenerateTypedNameReferences]
++public sealed partial class NanoChatEntry : BoxContainer
++{
++ public event Action? OnPressed;
++ private uint _number;
++ private Action? _pressHandler;
++
++ public NanoChatEntry()
++ {
++ RobustXamlLoader.Load(this);
++ }
++
++ public void SetRecipient(NanoChatRecipient recipient, uint number, bool isSelected)
++ {
++ // Remove old handler if it exists
++ if (_pressHandler != null)
++ ChatButton.OnPressed -= _pressHandler;
++
++ _number = number;
++
++ // Create and store new handler
++ _pressHandler = _ => OnPressed?.Invoke(_number);
++ ChatButton.OnPressed += _pressHandler;
++
++ NameLabel.Text = recipient.Name;
++ JobLabel.Text = recipient.JobTitle ?? "";
++ JobLabel.Visible = !string.IsNullOrEmpty(recipient.JobTitle);
++ UnreadIndicator.Visible = recipient.HasUnread;
++
++ ChatButton.ModulateSelfOverride = isSelected ? NanoChatMessageBubble.OwnMessageColor : null;
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml
+new file mode 100644
+index 00000000000..d3014fd337f
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml
+@@ -0,0 +1,21 @@
++
++
++
++
++
++
++
++
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml.cs
+new file mode 100644
+index 00000000000..50aadc4b62c
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatLogEntry.xaml.cs
+@@ -0,0 +1,17 @@
++using Robust.Client.AutoGenerated;
++using Robust.Client.UserInterface.Controls;
++using Robust.Client.UserInterface.XAML;
++
++namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
++
++[GenerateTypedNameReferences]
++public sealed partial class NanoChatLogEntry : BoxContainer
++{
++ public NanoChatLogEntry(int number, string time, string message)
++ {
++ RobustXamlLoader.Load(this);
++ NumberLabel.Text = number.ToString();
++ TimeLabel.Text = time;
++ MessageLabel.Text = message;
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml
+new file mode 100644
+index 00000000000..9b2390b6ae0
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml
+@@ -0,0 +1,55 @@
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml.cs
+new file mode 100644
+index 00000000000..91eb41c7751
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatMessageBubble.xaml.cs
+@@ -0,0 +1,62 @@
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Robust.Client.AutoGenerated;
++using Robust.Client.Graphics;
++using Robust.Client.UserInterface.Controls;
++using Robust.Client.UserInterface.XAML;
++
++namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
++
++[GenerateTypedNameReferences]
++public sealed partial class NanoChatMessageBubble : BoxContainer
++{
++ public static readonly Color OwnMessageColor = Color.FromHex("#173717d9"); // Dark green
++ public static readonly Color OtherMessageColor = Color.FromHex("#252525d9"); // Dark gray
++ public static readonly Color BorderColor = Color.FromHex("#40404066"); // Subtle border
++ public static readonly Color TextColor = Color.FromHex("#dcdcdc"); // Slightly softened white
++ public static readonly Color ErrorColor = Color.FromHex("#cc3333"); // Red
++
++ public NanoChatMessageBubble()
++ {
++ RobustXamlLoader.Load(this);
++ }
++
++ public void SetMessage(NanoChatMessage message, bool isOwnMessage)
++ {
++ if (MessagePanel.PanelOverride is not StyleBoxFlat)
++ return;
++
++ // Configure message appearance
++ var style = (StyleBoxFlat)MessagePanel.PanelOverride;
++ style.BackgroundColor = isOwnMessage ? OwnMessageColor : OtherMessageColor;
++ style.BorderColor = BorderColor;
++
++ // Set message content
++ MessageText.Text = message.Content;
++ MessageText.Modulate = TextColor;
++
++ // Show delivery failed text if needed (only for own messages)
++ DeliveryFailedLabel.Visible = isOwnMessage && message.DeliveryFailed;
++ if (DeliveryFailedLabel.Visible)
++ DeliveryFailedLabel.Modulate = ErrorColor;
++
++ // For own messages: FlexSpace -> MessagePanel -> RightSpacer
++ // For other messages: LeftSpacer -> MessagePanel -> FlexSpace
++ MessageContainer.RemoveAllChildren();
++
++ // fuuuuuck
++ MessageBox.Parent?.RemoveChild(MessageBox);
++
++ if (isOwnMessage)
++ {
++ MessageContainer.AddChild(FlexSpace);
++ MessageContainer.AddChild(MessageBox);
++ MessageContainer.AddChild(RightSpacer);
++ }
++ else
++ {
++ MessageContainer.AddChild(LeftSpacer);
++ MessageContainer.AddChild(MessageBox);
++ MessageContainer.AddChild(FlexSpace);
++ }
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUi.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUi.cs
+new file mode 100644
+index 00000000000..412ce4191e2
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUi.cs
+@@ -0,0 +1,43 @@
++using Content.Client.UserInterface.Fragments;
++using Content.Shared.CartridgeLoader;
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Robust.Client.UserInterface;
++
++namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
++
++public sealed partial class NanoChatUi : UIFragment
++{
++ private NanoChatUiFragment? _fragment;
++
++ public override Control GetUIFragmentRoot()
++ {
++ return _fragment!;
++ }
++
++ public override void Setup(BoundUserInterface userInterface, EntityUid? fragmentOwner)
++ {
++ _fragment = new NanoChatUiFragment();
++
++ _fragment.OnMessageSent += (type, number, content, job) =>
++ {
++ SendNanoChatUiMessage(type, number, content, job, userInterface);
++ };
++ }
++
++ public override void UpdateState(BoundUserInterfaceState state)
++ {
++ if (state is NanoChatUiState cast)
++ _fragment?.UpdateState(cast);
++ }
++
++ private static void SendNanoChatUiMessage(NanoChatUiMessageType type,
++ uint? number,
++ string? content,
++ string? job,
++ BoundUserInterface userInterface)
++ {
++ var nanoChatMessage = new NanoChatUiMessageEvent(type, number, content, job);
++ var message = new CartridgeUiMessage(nanoChatMessage);
++ userInterface.SendMessage(message);
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml
+new file mode 100644
+index 00000000000..e20595a6974
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml
+@@ -0,0 +1,167 @@
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml.cs
+new file mode 100644
+index 00000000000..ad3d62f0447
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiFragment.xaml.cs
+@@ -0,0 +1,254 @@
++using System.Linq;
++using System.Numerics;
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Robust.Client.AutoGenerated;
++using Robust.Client.UserInterface.Controls;
++using Robust.Client.UserInterface.XAML;
++using Robust.Client.UserInterface;
++using Robust.Shared.Timing;
++
++namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
++
++[GenerateTypedNameReferences]
++public sealed partial class NanoChatUiFragment : BoxContainer
++{
++ [Dependency] private readonly IGameTiming _timing = default!;
++
++ private const int MaxMessageLength = 256;
++
++ private readonly NewChatPopup _newChatPopup;
++ private uint? _currentChat;
++ private uint? _pendingChat;
++ private uint _ownNumber;
++ private bool _notificationsMuted;
++ private Dictionary _recipients = new();
++ private Dictionary> _messages = new();
++
++ public event Action? OnMessageSent;
++
++ public NanoChatUiFragment()
++ {
++ IoCManager.InjectDependencies(this);
++ RobustXamlLoader.Load(this);
++
++ _newChatPopup = new NewChatPopup();
++ SetupEventHandlers();
++ }
++
++ private void SetupEventHandlers()
++ {
++ _newChatPopup.OnChatCreated += (number, name, job) =>
++ {
++ OnMessageSent?.Invoke(NanoChatUiMessageType.NewChat, number, name, job);
++ };
++
++ NewChatButton.OnPressed += _ =>
++ {
++ _newChatPopup.ClearInputs();
++ _newChatPopup.OpenCentered();
++ };
++
++ MuteButton.OnPressed += _ =>
++ {
++ _notificationsMuted = !_notificationsMuted;
++ UpdateMuteButton();
++ OnMessageSent?.Invoke(NanoChatUiMessageType.ToggleMute, null, null, null);
++ };
++
++ MessageInput.OnTextChanged += args =>
++ {
++ var length = args.Text.Length;
++ var isValid = !string.IsNullOrWhiteSpace(args.Text) &&
++ length <= MaxMessageLength &&
++ (_currentChat != null || _pendingChat != null);
++
++ SendButton.Disabled = !isValid;
++
++ // Show character count when over limit
++ CharacterCount.Visible = length > MaxMessageLength;
++ if (length > MaxMessageLength)
++ {
++ CharacterCount.Text = Loc.GetString("nano-chat-message-too-long",
++ ("current", length),
++ ("max", MaxMessageLength));
++ CharacterCount.StyleClasses.Add("LabelDanger");
++ }
++ };
++
++ SendButton.OnPressed += _ => SendMessage();
++ DeleteChatButton.OnPressed += _ => DeleteCurrentChat();
++ }
++
++ private void SendMessage()
++ {
++ var activeChat = _pendingChat ?? _currentChat;
++ if (activeChat == null || string.IsNullOrWhiteSpace(MessageInput.Text))
++ return;
++
++ var messageContent = MessageInput.Text;
++
++ // Add predicted message
++ var predictedMessage = new NanoChatMessage(
++ _timing.CurTime,
++ messageContent,
++ _ownNumber
++ );
++
++ if (!_messages.TryGetValue(activeChat.Value, out var value))
++ {
++ value = new List();
++ _messages[activeChat.Value] = value;
++ }
++
++ value.Add(predictedMessage);
++
++ // Update UI with predicted message
++ UpdateMessages(_messages);
++
++ // Send message event
++ OnMessageSent?.Invoke(NanoChatUiMessageType.SendMessage, activeChat, messageContent, null);
++
++ // Clear input
++ MessageInput.Text = string.Empty;
++ SendButton.Disabled = true;
++ }
++
++ private void SelectChat(uint number)
++ {
++ // Don't reselect the same chat
++ if (_currentChat == number && _pendingChat == null)
++ return;
++
++ _pendingChat = number;
++
++ // Predict marking messages as read
++ if (_recipients.TryGetValue(number, out var recipient))
++ {
++ recipient.HasUnread = false;
++ _recipients[number] = recipient;
++ UpdateChatList(_recipients);
++ }
++
++ OnMessageSent?.Invoke(NanoChatUiMessageType.SelectChat, number, null, null);
++ UpdateCurrentChat();
++ }
++
++ private void DeleteCurrentChat()
++ {
++ var activeChat = _pendingChat ?? _currentChat;
++ if (activeChat == null)
++ return;
++
++ OnMessageSent?.Invoke(NanoChatUiMessageType.DeleteChat, activeChat, null, null);
++ }
++
++ private void UpdateChatList(Dictionary recipients)
++ {
++ ChatList.RemoveAllChildren();
++ _recipients = recipients;
++
++ NoChatsLabel.Visible = recipients.Count == 0;
++ if (NoChatsLabel.Parent != ChatList)
++ {
++ NoChatsLabel.Parent?.RemoveChild(NoChatsLabel);
++ ChatList.AddChild(NoChatsLabel);
++ }
++
++ foreach (var (number, recipient) in recipients.OrderBy(r => r.Value.Name))
++ {
++ var entry = new NanoChatEntry();
++ // For pending chat selection, always show it as selected even if unconfirmed
++ var isSelected = (_pendingChat == number) || (_pendingChat == null && _currentChat == number);
++ entry.SetRecipient(recipient, number, isSelected);
++ entry.OnPressed += SelectChat;
++ ChatList.AddChild(entry);
++ }
++ }
++
++ private void UpdateCurrentChat()
++ {
++ var activeChat = _pendingChat ?? _currentChat;
++ var hasActiveChat = activeChat != null;
++
++ // Update UI state
++ MessagesScroll.Visible = hasActiveChat;
++ CurrentChatName.Visible = !hasActiveChat;
++ MessageInputContainer.Visible = hasActiveChat;
++ DeleteChatButton.Visible = hasActiveChat;
++ DeleteChatButton.Disabled = !hasActiveChat;
++
++ if (activeChat != null && _recipients.TryGetValue(activeChat.Value, out var recipient))
++ {
++ CurrentChatName.Text = recipient.Name + (string.IsNullOrEmpty(recipient.JobTitle) ? "" : $" ({recipient.JobTitle})");
++ }
++ else
++ {
++ CurrentChatName.Text = Loc.GetString("nano-chat-select-chat");
++ }
++ }
++
++ private void UpdateMessages(Dictionary> messages)
++ {
++ _messages = messages;
++ MessageList.RemoveAllChildren();
++
++ var activeChat = _pendingChat ?? _currentChat;
++ if (activeChat == null || !messages.TryGetValue(activeChat.Value, out var chatMessages))
++ return;
++
++ foreach (var message in chatMessages)
++ {
++ var messageBubble = new NanoChatMessageBubble();
++ messageBubble.SetMessage(message, message.SenderId == _ownNumber);
++ MessageList.AddChild(messageBubble);
++
++ // Add spacing between messages
++ MessageList.AddChild(new Control { MinSize = new Vector2(0, 4) });
++ }
++
++ MessageList.InvalidateMeasure();
++ MessagesScroll.InvalidateMeasure();
++
++ // Scroll to bottom after messages are added
++ if (MessageList.Parent is ScrollContainer scroll)
++ scroll.SetScrollValue(new Vector2(0, float.MaxValue));
++ }
++
++ private void UpdateMuteButton()
++ {
++ if (BellMutedIcon != null)
++ BellMutedIcon.Visible = _notificationsMuted;
++ }
++
++ public void UpdateState(NanoChatUiState state)
++ {
++ _ownNumber = state.OwnNumber;
++ _notificationsMuted = state.NotificationsMuted;
++ OwnNumberLabel.Text = $"#{state.OwnNumber:D4}";
++ UpdateMuteButton();
++
++ // Update new chat button state based on recipient limit
++ var atLimit = state.Recipients.Count >= state.MaxRecipients;
++ NewChatButton.Disabled = atLimit;
++ NewChatButton.ToolTip = atLimit
++ ? Loc.GetString("nano-chat-max-recipients")
++ : Loc.GetString("nano-chat-new-chat");
++
++ // First handle pending chat resolution if we have one
++ if (_pendingChat != null)
++ {
++ if (_pendingChat == state.CurrentChat)
++ _currentChat = _pendingChat; // Server confirmed our selection
++
++ _pendingChat = null; // Clear pending either way
++ }
++
++ // No pending chat or it was just cleared, update current directly
++ if (_pendingChat == null)
++ _currentChat = state.CurrentChat;
++
++ UpdateCurrentChat();
++ UpdateChatList(state.Recipients);
++ UpdateMessages(state.Messages);
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml
+new file mode 100644
+index 00000000000..3233ec11b21
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml
+@@ -0,0 +1,52 @@
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml.cs b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml.cs
+new file mode 100644
+index 00000000000..65a1af7675d
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/CartridgeLoader/Cartridges/NewChatPopup.xaml.cs
+@@ -0,0 +1,87 @@
++using System.Linq;
++using Robust.Client.AutoGenerated;
++using Robust.Client.UserInterface.CustomControls;
++using Robust.Client.UserInterface.XAML;
++
++namespace Content.Client._CorvaxNext.CartridgeLoader.Cartridges;
++
++[GenerateTypedNameReferences]
++public sealed partial class NewChatPopup : DefaultWindow
++{
++ private const int MaxInputLength = 16;
++ private const int MaxNumberLength = 4; // i hardcoded it to be 4 so suffer
++
++ public event Action? OnChatCreated;
++
++ public NewChatPopup()
++ {
++ RobustXamlLoader.Load(this);
++
++ // margins trolling
++ ContentsContainer.Margin = new Thickness(3);
++
++ // Button handlers
++ CancelButton.OnPressed += _ => Close();
++ CreateButton.OnPressed += _ => CreateChat();
++
++ // Input validation
++ NumberInput.OnTextChanged += _ => ValidateInputs();
++ NameInput.OnTextChanged += _ => ValidateInputs();
++
++ // Input validation
++ NumberInput.OnTextChanged += args =>
++ {
++ if (args.Text.Length > MaxNumberLength)
++ NumberInput.Text = args.Text[..MaxNumberLength];
++
++ // Filter to digits only
++ var newText = string.Concat(NumberInput.Text.Where(char.IsDigit));
++ if (newText != NumberInput.Text)
++ NumberInput.Text = newText;
++
++ ValidateInputs();
++ };
++
++ NameInput.OnTextChanged += args =>
++ {
++ if (args.Text.Length > MaxInputLength)
++ NameInput.Text = args.Text[..MaxInputLength];
++ ValidateInputs();
++ };
++
++ JobInput.OnTextChanged += args =>
++ {
++ if (args.Text.Length > MaxInputLength)
++ JobInput.Text = args.Text[..MaxInputLength];
++ };
++ }
++
++ private void ValidateInputs()
++ {
++ var isValid = !string.IsNullOrWhiteSpace(NumberInput.Text) &&
++ !string.IsNullOrWhiteSpace(NameInput.Text) &&
++ uint.TryParse(NumberInput.Text, out _);
++
++ CreateButton.Disabled = !isValid;
++ }
++
++ private void CreateChat()
++ {
++ if (!uint.TryParse(NumberInput.Text, out var number))
++ return;
++
++ var name = NameInput.Text.Trim();
++ var job = string.IsNullOrWhiteSpace(JobInput.Text) ? null : JobInput.Text.Trim();
++
++ OnChatCreated?.Invoke(number, name, job);
++ Close();
++ }
++
++ public void ClearInputs()
++ {
++ NumberInput.Text = string.Empty;
++ NameInput.Text = string.Empty;
++ JobInput.Text = string.Empty;
++ ValidateInputs();
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Client/_CorvaxNext/NanoChat/NanoChatSystem.cs b/Content.Client/_CorvaxNext/NanoChat/NanoChatSystem.cs
+new file mode 100644
+index 00000000000..f9b33334de3
+--- /dev/null
++++ b/Content.Client/_CorvaxNext/NanoChat/NanoChatSystem.cs
+@@ -0,0 +1,5 @@
++using Content.Shared._CorvaxNext.NanoChat;
++
++namespace Content.Client._CorvaxNext.NanoChat;
++
++public sealed class NanoChatSystem : SharedNanoChatSystem;
+\ No newline at end of file
+diff --git a/Content.Server/Access/Systems/AgentIDCardSystem.cs b/Content.Server/Access/Systems/AgentIDCardSystem.cs
+index a38aefce935..07090402359 100644
+--- a/Content.Server/Access/Systems/AgentIDCardSystem.cs
++++ b/Content.Server/Access/Systems/AgentIDCardSystem.cs
+@@ -9,6 +9,7 @@
+ using Robust.Shared.Prototypes;
+ using Content.Shared.Roles;
+ using System.Diagnostics.CodeAnalysis;
++using Content.Shared._CorvaxNext.NanoChat;
+
+ namespace Content.Server.Access.Systems
+ {
+@@ -18,6 +19,7 @@ public sealed class AgentIDCardSystem : SharedAgentIdCardSystem
+ [Dependency] private readonly IdCardSystem _cardSystem = default!;
+ [Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
++ [Dependency] private readonly SharedNanoChatSystem _nanoChat = default!; // Corvax-Next-PDAChat
+
+ public override void Initialize()
+ {
+@@ -28,6 +30,17 @@ public override void Initialize()
+ SubscribeLocalEvent(OnNameChanged);
+ SubscribeLocalEvent(OnJobChanged);
+ SubscribeLocalEvent(OnJobIconChanged);
++ SubscribeLocalEvent(OnNumberChanged); // Corvax-Next-PDAChat
++ }
++
++ // Corvax-Next-PDAChat - Add number change handler
++ private void OnNumberChanged(Entity ent, ref AgentIDCardNumberChangedMessage args)
++ {
++ if (!TryComp(ent, out var comp))
++ return;
++
++ _nanoChat.SetNumber((ent, comp), args.Number);
++ Dirty(ent, comp);
+ }
+
+ private void OnAfterInteract(EntityUid uid, AgentIDCardComponent component, AfterInteractEvent args)
+@@ -41,6 +54,34 @@ private void OnAfterInteract(EntityUid uid, AgentIDCardComponent component, Afte
+ var beforeLength = access.Tags.Count;
+ access.Tags.UnionWith(targetAccess.Tags);
+ var addedLength = access.Tags.Count - beforeLength;
++
++ // Corvax-Next-PDAChat-Start - Copy NanoChat data if available
++ if (TryComp(args.Target, out var targetNanoChat) &&
++ TryComp(uid, out var agentNanoChat))
++ {
++ // First clear existing data
++ _nanoChat.Clear((uid, agentNanoChat));
++
++ // Copy the number
++ if (_nanoChat.GetNumber((args.Target.Value, targetNanoChat)) is { } number)
++ _nanoChat.SetNumber((uid, agentNanoChat), number);
++
++ // Copy all recipients and their messages
++ foreach (var (recipientNumber, recipient) in _nanoChat.GetRecipients((args.Target.Value, targetNanoChat)))
++ {
++ _nanoChat.SetRecipient((uid, agentNanoChat), recipientNumber, recipient);
++
++ if (_nanoChat.GetMessagesForRecipient((args.Target.Value, targetNanoChat), recipientNumber) is not
++ { } messages)
++ continue;
++
++ foreach (var message in messages)
++ {
++ _nanoChat.AddMessage((uid, agentNanoChat), recipientNumber, message);
++ }
++ }
++ }
++ // Corvax-Next-PDAChat-End
+
+ if (addedLength == 0)
+ {
+@@ -67,7 +108,17 @@ private void AfterUIOpen(EntityUid uid, AgentIDCardComponent component, AfterAct
+ if (!TryComp(uid, out var idCard))
+ return;
+
+- var state = new AgentIDCardBoundUserInterfaceState(idCard.FullName ?? "", idCard.LocalizedJobTitle ?? "", idCard.JobIcon);
++ // Corvax-Next-PDAChat-Start - Get current number if it exists
++ uint? currentNumber = null;
++ if (TryComp(uid, out var comp))
++ currentNumber = comp.Number;
++
++ var state = new AgentIDCardBoundUserInterfaceState(
++ idCard.FullName ?? "",
++ idCard.LocalizedJobTitle ?? "",
++ idCard.JobIcon,
++ currentNumber); // Corvax-Next-PDAChat-End - Pass current number
++
+ _uiSystem.SetUiState(uid, AgentIDCardUiKey.Key, state);
+ }
+
+diff --git a/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeComponent.cs b/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeComponent.cs
+index cfa92dd67f7..e8e54c32485 100644
+--- a/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeComponent.cs
++++ b/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeComponent.cs
+@@ -1,4 +1,5 @@
+ using Content.Shared.CartridgeLoader.Cartridges;
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
+ using Robust.Shared.Audio;
+
+ namespace Content.Server.CartridgeLoader.Cartridges;
+@@ -18,4 +19,10 @@ public sealed partial class LogProbeCartridgeComponent : Component
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public SoundSpecifier SoundScan = new SoundPathSpecifier("/Audio/Machines/scan_finish.ogg");
++
++ ///
++ /// Corvax-Next-PDAChat: The last scanned NanoChat data, if any
++ ///
++ [DataField]
++ public NanoChatData? ScannedNanoChatData;
+ }
+diff --git a/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.cs b/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.cs
+index f5ccea95900..307c8f05fa5 100644
+--- a/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.cs
++++ b/Content.Server/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.cs
+@@ -3,12 +3,13 @@
+ using Content.Shared.CartridgeLoader;
+ using Content.Shared.CartridgeLoader.Cartridges;
+ using Content.Shared.Popups;
++using Content.Shared._CorvaxNext.NanoChat;
+ using Robust.Shared.Audio.Systems;
+ using Robust.Shared.Random;
+
+ namespace Content.Server.CartridgeLoader.Cartridges;
+
+-public sealed class LogProbeCartridgeSystem : EntitySystem
++public sealed partial class LogProbeCartridgeSystem : EntitySystem // Corvax-Next-PDAChat - Made partial
+ {
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly CartridgeLoaderSystem? _cartridgeLoaderSystem = default!;
+@@ -18,6 +19,7 @@ public sealed class LogProbeCartridgeSystem : EntitySystem
+ public override void Initialize()
+ {
+ base.Initialize();
++ InitializeNanoChat(); // Corvax-Next-PDAChat
+ SubscribeLocalEvent(OnUiReady);
+ SubscribeLocalEvent(AfterInteract);
+ }
+@@ -33,6 +35,15 @@ private void AfterInteract(Entity ent, ref Cartridge
+ if (args.InteractEvent.Handled || !args.InteractEvent.CanReach || args.InteractEvent.Target is not { } target)
+ return;
+
++ // Corvax-Next-PDAChat-Start - Add NanoChat card scanning
++ if (TryComp(target, out var nanoChatCard))
++ {
++ ScanNanoChatCard(ent, args, target, nanoChatCard);
++ args.InteractEvent.Handled = true;
++ return;
++ }
++ // Corvax-Next-PDAChat-End
++
+ if (!TryComp(target, out AccessReaderComponent? accessReaderComponent))
+ return;
+
+@@ -41,6 +52,7 @@ private void AfterInteract(Entity ent, ref Cartridge
+ _popupSystem.PopupCursor(Loc.GetString("log-probe-scan", ("device", target)), args.InteractEvent.User);
+
+ ent.Comp.PulledAccessLogs.Clear();
++ ent.Comp.ScannedNanoChatData = null; // Corvax-Next-PDAChat - Clear any previous NanoChat data
+
+ foreach (var accessRecord in accessReaderComponent.AccessLog)
+ {
+@@ -65,7 +77,7 @@ private void OnUiReady(Entity ent, ref CartridgeUiRe
+
+ private void UpdateUiState(Entity ent, EntityUid loaderUid)
+ {
+- var state = new LogProbeUiState(ent.Comp.PulledAccessLogs);
++ var state = new LogProbeUiState(ent.Comp.PulledAccessLogs, ent.Comp.ScannedNanoChatData); // Corvax-Next-PDAChat - NanoChat support
+ _cartridgeLoaderSystem?.UpdateCartridgeUiState(loaderUid, state);
+ }
+ }
+diff --git a/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.NanoChat.cs b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.NanoChat.cs
+new file mode 100644
+index 00000000000..bd3b33836e6
+--- /dev/null
++++ b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/LogProbeCartridgeSystem.NanoChat.cs
+@@ -0,0 +1,82 @@
++using Content.Shared.Audio;
++using Content.Shared.CartridgeLoader;
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Content.Shared._CorvaxNext.NanoChat;
++
++namespace Content.Server.CartridgeLoader.Cartridges;
++
++public sealed partial class LogProbeCartridgeSystem
++{
++ private void InitializeNanoChat()
++ {
++ SubscribeLocalEvent(OnRecipientUpdated);
++ SubscribeLocalEvent(OnMessageReceived);
++ }
++
++ private void OnRecipientUpdated(ref NanoChatRecipientUpdatedEvent args)
++ {
++ var query = EntityQueryEnumerator();
++ while (query.MoveNext(out var uid, out var probe, out var cartridge))
++ {
++ if (probe.ScannedNanoChatData == null || GetEntity(probe.ScannedNanoChatData.Value.Card) != args.CardUid)
++ continue;
++
++ if (!TryComp(args.CardUid, out var card))
++ continue;
++
++ probe.ScannedNanoChatData = new NanoChatData(
++ new Dictionary(card.Recipients),
++ probe.ScannedNanoChatData.Value.Messages,
++ card.Number,
++ GetNetEntity(args.CardUid));
++
++ if (cartridge.LoaderUid != null)
++ UpdateUiState((uid, probe), cartridge.LoaderUid.Value);
++ }
++ }
++
++ private void OnMessageReceived(ref NanoChatMessageReceivedEvent args)
++ {
++ var query = EntityQueryEnumerator();
++ while (query.MoveNext(out var uid, out var probe, out var cartridge))
++ {
++ if (probe.ScannedNanoChatData == null || GetEntity(probe.ScannedNanoChatData.Value.Card) != args.CardUid)
++ continue;
++
++ if (!TryComp(args.CardUid, out var card))
++ continue;
++
++ probe.ScannedNanoChatData = new NanoChatData(
++ probe.ScannedNanoChatData.Value.Recipients,
++ new Dictionary>(card.Messages),
++ card.Number,
++ GetNetEntity(args.CardUid));
++
++ if (cartridge.LoaderUid != null)
++ UpdateUiState((uid, probe), cartridge.LoaderUid.Value);
++ }
++ }
++
++ private void ScanNanoChatCard(Entity ent,
++ CartridgeAfterInteractEvent args,
++ EntityUid target,
++ NanoChatCardComponent card)
++ {
++ _audioSystem.PlayEntity(ent.Comp.SoundScan,
++ args.InteractEvent.User,
++ target,
++ AudioHelpers.WithVariation(0.25f, _random));
++ _popupSystem.PopupCursor(Loc.GetString("log-probe-scan-nanochat", ("card", target)), args.InteractEvent.User);
++
++ ent.Comp.PulledAccessLogs.Clear();
++
++ ent.Comp.ScannedNanoChatData = new NanoChatData(
++ new Dictionary(card.Recipients),
++ new Dictionary>(card.Messages),
++ card.Number,
++ GetNetEntity(target)
++ );
++
++ UpdateUiState(ent, args.Loader);
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeComponent.cs b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeComponent.cs
+new file mode 100644
+index 00000000000..e424363bc3a
+--- /dev/null
++++ b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeComponent.cs
+@@ -0,0 +1,26 @@
++using Content.Shared.Radio;
++using Robust.Shared.Prototypes;
++
++namespace Content.Server._CorvaxNext.CartridgeLoader.Cartridges;
++
++[RegisterComponent, Access(typeof(NanoChatCartridgeSystem))]
++public sealed partial class NanoChatCartridgeComponent : Component
++{
++ ///
++ /// Station entity to keep track of.
++ ///
++ [DataField]
++ public EntityUid? Station;
++
++ ///
++ /// The NanoChat card to keep track of.
++ ///
++ [DataField]
++ public EntityUid? Card;
++
++ ///
++ /// The required to send or receive messages.
++ ///
++ [DataField]
++ public ProtoId RadioChannel = "Common";
++}
+\ No newline at end of file
+diff --git a/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeSystem.cs b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeSystem.cs
+new file mode 100644
+index 00000000000..aeff1d50c49
+--- /dev/null
++++ b/Content.Server/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatCartridgeSystem.cs
+@@ -0,0 +1,514 @@
++using System.Linq;
++using Content.Server.Administration.Logs;
++using Content.Server.CartridgeLoader;
++using Content.Server.Power.Components;
++using Content.Server.Radio;
++using Content.Server.Radio.Components;
++using Content.Server.Station.Systems;
++using Content.Shared.Access.Components;
++using Content.Shared.CartridgeLoader;
++using Content.Shared.Database;
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Content.Shared._CorvaxNext.NanoChat;
++using Content.Shared.PDA;
++using Content.Shared.Radio.Components;
++using Robust.Shared.Prototypes;
++using Robust.Shared.Timing;
++
++namespace Content.Server._CorvaxNext.CartridgeLoader.Cartridges;
++
++public sealed class NanoChatCartridgeSystem : EntitySystem
++{
++ [Dependency] private readonly CartridgeLoaderSystem _cartridge = default!;
++ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
++ [Dependency] private readonly IGameTiming _timing = default!;
++ [Dependency] private readonly IPrototypeManager _prototype = default!;
++ [Dependency] private readonly SharedNanoChatSystem _nanoChat = default!;
++ [Dependency] private readonly StationSystem _station = default!;
++
++ // Messages in notifications get cut off after this point
++ // no point in storing it on the comp
++ private const int NotificationMaxLength = 64;
++
++ public override void Initialize()
++ {
++ base.Initialize();
++
++ SubscribeLocalEvent(OnUiReady);
++ SubscribeLocalEvent(OnMessage);
++ }
++
++ public override void Update(float frameTime)
++ {
++ base.Update(frameTime);
++
++ // Update card references for any cartridges that need it
++ var query = EntityQueryEnumerator();
++ while (query.MoveNext(out var uid, out var nanoChat, out var cartridge))
++ {
++ if (cartridge.LoaderUid == null)
++ continue;
++
++ // Check if we need to update our card reference
++ if (!TryComp(cartridge.LoaderUid, out var pda))
++ continue;
++
++ var newCard = pda.ContainedId;
++ var currentCard = nanoChat.Card;
++
++ // If the cards match, nothing to do
++ if (newCard == currentCard)
++ continue;
++
++ // Update card reference
++ nanoChat.Card = newCard;
++
++ // Update UI state since card reference changed
++ UpdateUI((uid, nanoChat), cartridge.LoaderUid.Value);
++ }
++ }
++
++ ///
++ /// Handles incoming UI messages from the NanoChat cartridge.
++ ///
++ private void OnMessage(Entity ent, ref CartridgeMessageEvent args)
++ {
++ if (args is not NanoChatUiMessageEvent msg)
++ return;
++
++ if (!GetCardEntity(GetEntity(args.LoaderUid), out var card))
++ return;
++
++ switch (msg.Type)
++ {
++ case NanoChatUiMessageType.NewChat:
++ HandleNewChat(card, msg);
++ break;
++ case NanoChatUiMessageType.SelectChat:
++ HandleSelectChat(card, msg);
++ break;
++ case NanoChatUiMessageType.CloseChat:
++ HandleCloseChat(card);
++ break;
++ case NanoChatUiMessageType.ToggleMute:
++ HandleToggleMute(card);
++ break;
++ case NanoChatUiMessageType.DeleteChat:
++ HandleDeleteChat(card, msg);
++ break;
++ case NanoChatUiMessageType.SendMessage:
++ HandleSendMessage(ent, card, msg);
++ break;
++ }
++
++ UpdateUI(ent, GetEntity(args.LoaderUid));
++ }
++
++ ///
++ /// Gets the ID card entity associated with a PDA.
++ ///
++ /// The PDA entity ID
++ /// Output parameter containing the found card entity and component
++ /// True if a valid NanoChat card was found
++ private bool GetCardEntity(
++ EntityUid loaderUid,
++ out Entity card)
++ {
++ card = default;
++
++ // Get the PDA and check if it has an ID card
++ if (!TryComp(loaderUid, out var pda) ||
++ pda.ContainedId == null ||
++ !TryComp(pda.ContainedId, out var idCard))
++ return false;
++
++ card = (pda.ContainedId.Value, idCard);
++ return true;
++ }
++
++ ///
++ /// Handles creation of a new chat conversation.
++ ///
++ private void HandleNewChat(Entity card, NanoChatUiMessageEvent msg)
++ {
++ if (msg.RecipientNumber == null || msg.Content == null || msg.RecipientNumber == card.Comp.Number)
++ return;
++
++ // Add new recipient
++ var recipient = new NanoChatRecipient(msg.RecipientNumber.Value,
++ msg.Content,
++ msg.RecipientJob);
++
++ // Initialize or update recipient
++ _nanoChat.SetRecipient((card, card.Comp), msg.RecipientNumber.Value, recipient);
++
++ _adminLogger.Add(LogType.Action,
++ LogImpact.Low,
++ $"{ToPrettyString(msg.Actor):user} created new NanoChat conversation with #{msg.RecipientNumber:D4} ({msg.Content})");
++
++ var recipientEv = new NanoChatRecipientUpdatedEvent(card);
++ RaiseLocalEvent(ref recipientEv);
++ UpdateUIForCard(card);
++ }
++
++ ///
++ /// Handles selecting a chat conversation.
++ ///
++ private void HandleSelectChat(Entity card, NanoChatUiMessageEvent msg)
++ {
++ if (msg.RecipientNumber == null)
++ return;
++
++ _nanoChat.SetCurrentChat((card, card.Comp), msg.RecipientNumber);
++
++ // Clear unread flag when selecting chat
++ if (_nanoChat.GetRecipient((card, card.Comp), msg.RecipientNumber.Value) is { } recipient)
++ {
++ _nanoChat.SetRecipient((card, card.Comp),
++ msg.RecipientNumber.Value,
++ recipient with { HasUnread = false });
++ }
++ }
++
++ ///
++ /// Handles closing the current chat conversation.
++ ///
++ private void HandleCloseChat(Entity card)
++ {
++ _nanoChat.SetCurrentChat((card, card.Comp), null);
++ }
++
++ ///
++ /// Handles deletion of a chat conversation.
++ ///
++ private void HandleDeleteChat(Entity card, NanoChatUiMessageEvent msg)
++ {
++ if (msg.RecipientNumber == null || card.Comp.Number == null)
++ return;
++
++ // Delete chat but keep the messages
++ var deleted = _nanoChat.TryDeleteChat((card, card.Comp), msg.RecipientNumber.Value, true);
++
++ if (!deleted)
++ return;
++
++ _adminLogger.Add(LogType.Action,
++ LogImpact.Low,
++ $"{ToPrettyString(msg.Actor):user} deleted NanoChat conversation with #{msg.RecipientNumber:D4}");
++
++ UpdateUIForCard(card);
++ }
++
++ ///
++ /// Handles toggling notification mute state.
++ ///
++ private void HandleToggleMute(Entity card)
++ {
++ _nanoChat.SetNotificationsMuted((card, card.Comp), !_nanoChat.GetNotificationsMuted((card, card.Comp)));
++ UpdateUIForCard(card);
++ }
++
++ ///
++ /// Handles sending a new message in a chat conversation.
++ ///
++ private void HandleSendMessage(Entity cartridge,
++ Entity card,
++ NanoChatUiMessageEvent msg)
++ {
++ if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
++ return;
++
++ if (!EnsureRecipientExists(card, msg.RecipientNumber.Value))
++ return;
++
++ // Create and store message for sender
++ var message = new NanoChatMessage(
++ _timing.CurTime,
++ msg.Content,
++ (uint)card.Comp.Number
++ );
++
++ // Attempt delivery
++ var (deliveryFailed, recipients) = AttemptMessageDelivery(cartridge, msg.RecipientNumber.Value);
++
++ // Update delivery status
++ message = message with { DeliveryFailed = deliveryFailed };
++
++ // Store message in sender's outbox under recipient's number
++ _nanoChat.AddMessage((card, card.Comp), msg.RecipientNumber.Value, message);
++
++ // Log message attempt
++ var recipientsText = recipients.Count > 0
++ ? string.Join(", ", recipients.Select(r => ToPrettyString(r)))
++ : $"#{msg.RecipientNumber:D4}";
++
++ _adminLogger.Add(LogType.Chat,
++ LogImpact.Low,
++ $"{ToPrettyString(card):user} sent NanoChat message to {recipientsText}: {msg.Content}{(deliveryFailed ? " [DELIVERY FAILED]" : "")}");
++
++ var msgEv = new NanoChatMessageReceivedEvent(card);
++ RaiseLocalEvent(ref msgEv);
++
++ if (deliveryFailed)
++ return;
++
++ foreach (var recipient in recipients)
++ {
++ DeliverMessageToRecipient(card, recipient, message);
++ }
++ }
++
++ ///
++ /// Ensures a recipient exists in the sender's contacts.
++ ///
++ /// The card to check contacts for
++ /// The recipient's number to check
++ /// True if the recipient exists or was created successfully
++ private bool EnsureRecipientExists(Entity card, uint recipientNumber)
++ {
++ return _nanoChat.EnsureRecipientExists((card, card.Comp), recipientNumber, GetCardInfo(recipientNumber));
++ }
++
++ ///
++ /// Attempts to deliver a message to recipients.
++ ///
++ /// The sending cartridge entity
++ /// The recipient's number
++ /// Tuple containing delivery status and recipients if found.
++ private (bool failed, List> recipient) AttemptMessageDelivery(
++ Entity sender,
++ uint recipientNumber)
++ {
++ // First verify we can send from this device
++ var channel = _prototype.Index(sender.Comp.RadioChannel);
++ var sendAttemptEvent = new RadioSendAttemptEvent(channel, sender);
++ RaiseLocalEvent(ref sendAttemptEvent);
++ if (sendAttemptEvent.Cancelled)
++ return (true, new List>());
++
++ var foundRecipients = new List>();
++
++ // Find all cards with matching number
++ var cardQuery = EntityQueryEnumerator();
++ while (cardQuery.MoveNext(out var cardUid, out var card))
++ {
++ if (card.Number != recipientNumber)
++ continue;
++
++ foundRecipients.Add((cardUid, card));
++ }
++
++ if (foundRecipients.Count == 0)
++ return (true, foundRecipients);
++
++ // Now check if any of these cards can receive
++ var deliverableRecipients = new List>();
++ foreach (var recipient in foundRecipients)
++ {
++ // Find any cartridges that have this card
++ var cartridgeQuery = EntityQueryEnumerator();
++ while (cartridgeQuery.MoveNext(out var receiverUid, out var receiverCart, out _))
++ {
++ if (receiverCart.Card != recipient.Owner)
++ continue;
++
++ // Check if devices are on same station/map
++ var recipientStation = _station.GetOwningStation(receiverUid);
++ var senderStation = _station.GetOwningStation(sender);
++
++ // Both entities must be on a station
++ if (recipientStation == null || senderStation == null)
++ continue;
++
++ // Must be on same map/station unless long range allowed
++ if (!channel.LongRange && recipientStation != senderStation)
++ continue;
++
++ // Needs telecomms
++ if (!HasActiveServer(senderStation.Value) || !HasActiveServer(recipientStation.Value))
++ continue;
++
++ // Check if recipient can receive
++ var receiveAttemptEv = new RadioReceiveAttemptEvent(channel, sender, receiverUid);
++ RaiseLocalEvent(ref receiveAttemptEv);
++ if (receiveAttemptEv.Cancelled)
++ continue;
++
++ // Found valid cartridge that can receive
++ deliverableRecipients.Add(recipient);
++ break; // Only need one valid cartridge per card
++ }
++ }
++
++ return (deliverableRecipients.Count == 0, deliverableRecipients);
++ }
++
++ ///
++ /// Checks if there are any active telecomms servers on the given station
++ ///
++ private bool HasActiveServer(EntityUid station)
++ {
++ // I have no idea why this isn't public in the RadioSystem
++ var query =
++ EntityQueryEnumerator();
++
++ while (query.MoveNext(out var uid, out _, out _, out var power))
++ {
++ if (_station.GetOwningStation(uid) == station && power.Powered)
++ return true;
++ }
++
++ return false;
++ }
++
++ ///
++ /// Delivers a message to the recipient and handles associated notifications.
++ ///
++ /// The sender's card entity
++ /// The recipient's card entity
++ /// The to deliver
++ private void DeliverMessageToRecipient(Entity sender,
++ Entity recipient,
++ NanoChatMessage message)
++ {
++ var senderNumber = sender.Comp.Number;
++ if (senderNumber == null)
++ return;
++
++ // Always try to get and add sender info to recipient's contacts
++ if (!EnsureRecipientExists(recipient, senderNumber.Value))
++ return;
++
++ _nanoChat.AddMessage((recipient, recipient.Comp), senderNumber.Value, message with { DeliveryFailed = false });
++
++
++ if (_nanoChat.GetCurrentChat((recipient, recipient.Comp)) != senderNumber)
++ HandleUnreadNotification(recipient, message);
++
++ var msgEv = new NanoChatMessageReceivedEvent(recipient);
++ RaiseLocalEvent(ref msgEv);
++ UpdateUIForCard(recipient);
++ }
++
++ ///
++ /// Handles unread message notifications and updates unread status.
++ ///
++ private void HandleUnreadNotification(Entity recipient, NanoChatMessage message)
++ {
++ // Get sender name from contacts or fall back to number
++ var recipients = _nanoChat.GetRecipients((recipient, recipient.Comp));
++ var senderName = recipients.TryGetValue(message.SenderId, out var existingRecipient)
++ ? existingRecipient.Name
++ : $"#{message.SenderId:D4}";
++
++ if (!recipient.Comp.Recipients[message.SenderId].HasUnread && !recipient.Comp.NotificationsMuted)
++ {
++ var pdaQuery = EntityQueryEnumerator();
++ while (pdaQuery.MoveNext(out var pdaUid, out var pdaComp))
++ {
++ if (pdaComp.ContainedId != recipient)
++ continue;
++
++ _cartridge.SendNotification(pdaUid,
++ Loc.GetString("nano-chat-new-message-title", ("sender", senderName)),
++ Loc.GetString("nano-chat-new-message-body", ("message", TruncateMessage(message.Content))));
++ break;
++ }
++ }
++
++ // Update unread status
++ _nanoChat.SetRecipient((recipient, recipient.Comp),
++ message.SenderId,
++ existingRecipient with { HasUnread = true });
++ }
++
++ ///
++ /// Updates the UI for any PDAs containing the specified card.
++ ///
++ private void UpdateUIForCard(EntityUid cardUid)
++ {
++ // Find any PDA containing this card and update its UI
++ var query = EntityQueryEnumerator();
++ while (query.MoveNext(out var uid, out var comp, out var cartridge))
++ {
++ if (comp.Card != cardUid || cartridge.LoaderUid == null)
++ continue;
++
++ UpdateUI((uid, comp), cartridge.LoaderUid.Value);
++ }
++ }
++
++ ///
++ /// Gets the for a given NanoChat number.
++ ///
++ private NanoChatRecipient? GetCardInfo(uint number)
++ {
++ // Find card with this number to get its info
++ var query = EntityQueryEnumerator();
++ while (query.MoveNext(out var uid, out var card))
++ {
++ if (card.Number != number)
++ continue;
++
++ // Try to get job title from ID card if possible
++ string? jobTitle = null;
++ var name = "Unknown";
++ if (TryComp(uid, out var idCard))
++ {
++ jobTitle = idCard.LocalizedJobTitle;
++ name = idCard.FullName ?? name;
++ }
++
++ return new NanoChatRecipient(number, name, jobTitle);
++ }
++
++ return null;
++ }
++
++ ///
++ /// Truncates a message to the notification maximum length.
++ ///
++ private static string TruncateMessage(string message)
++ {
++ return message.Length <= NotificationMaxLength
++ ? message
++ : message[..(NotificationMaxLength - 4)] + " [...]";
++ }
++
++ private void OnUiReady(Entity ent, ref CartridgeUiReadyEvent args)
++ {
++ _cartridge.RegisterBackgroundProgram(args.Loader, ent);
++ UpdateUI(ent, args.Loader);
++ }
++
++ private void UpdateUI(Entity ent, EntityUid loader)
++ {
++ if (_station.GetOwningStation(loader) is { } station)
++ ent.Comp.Station = station;
++
++ var recipients = new Dictionary();
++ var messages = new Dictionary>();
++ uint? currentChat = null;
++ uint ownNumber = 0;
++ var maxRecipients = 50;
++ var notificationsMuted = false;
++
++ if (ent.Comp.Card != null && TryComp(ent.Comp.Card, out var card))
++ {
++ recipients = card.Recipients;
++ messages = card.Messages;
++ currentChat = card.CurrentChat;
++ ownNumber = card.Number ?? 0;
++ maxRecipients = card.MaxRecipients;
++ notificationsMuted = card.NotificationsMuted;
++ }
++
++ var state = new NanoChatUiState(recipients,
++ messages,
++ currentChat,
++ ownNumber,
++ maxRecipients,
++ notificationsMuted);
++ _cartridge.UpdateCartridgeUiState(loader, state);
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Server/_CorvaxNext/NanoChat/NanoChatSystem.cs b/Content.Server/_CorvaxNext/NanoChat/NanoChatSystem.cs
+new file mode 100644
+index 00000000000..14d1c8f801d
+--- /dev/null
++++ b/Content.Server/_CorvaxNext/NanoChat/NanoChatSystem.cs
+@@ -0,0 +1,130 @@
++using System.Linq;
++using Content.Server.Access.Systems;
++using Content.Server.Administration.Logs;
++using Content.Server.Kitchen.Components;
++using Content.Server.NameIdentifier;
++using Content.Shared.Database;
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Content.Shared._CorvaxNext.NanoChat;
++using Content.Shared.NameIdentifier;
++using Robust.Shared.Prototypes;
++using Robust.Shared.Random;
++
++namespace Content.Server._CorvaxNext.NanoChat;
++
++///
++/// Handles NanoChat features that are specific to the server but not related to the cartridge itself.
++///
++public sealed class NanoChatSystem : SharedNanoChatSystem
++{
++ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
++ [Dependency] private readonly IRobustRandom _random = default!;
++ [Dependency] private readonly NameIdentifierSystem _name = default!;
++
++ private readonly ProtoId _nameIdentifierGroup = "NanoChat";
++
++ public override void Initialize()
++ {
++ base.Initialize();
++ SubscribeLocalEvent(OnCardInit);
++ SubscribeLocalEvent(OnMicrowaved, after: [typeof(IdCardSystem)]);
++ }
++
++ private void OnMicrowaved(Entity ent, ref BeingMicrowavedEvent args)
++ {
++ // Skip if the entity was deleted (e.g., by ID card system burning it)
++ if (Deleted(ent))
++ return;
++
++ if (!TryComp(args.Microwave, out var micro) || micro.Broken)
++ return;
++
++ var randomPick = _random.NextFloat();
++
++ // Super lucky - erase all messages (10% chance)
++ if (randomPick <= 0.10f)
++ {
++ ent.Comp.Messages.Clear();
++ // TODO: these shouldn't be shown at the same time as the popups from IdCardSystem
++ // _popup.PopupEntity(Loc.GetString("nanochat-card-microwave-erased", ("card", ent)),
++ // ent,
++ // PopupType.Medium);
++
++ _adminLogger.Add(LogType.Action,
++ LogImpact.Medium,
++ $"{ToPrettyString(args.Microwave)} erased all messages on {ToPrettyString(ent)}");
++ }
++ else
++ {
++ // Scramble random messages for random recipients
++ ScrambleMessages(ent);
++ // _popup.PopupEntity(Loc.GetString("nanochat-card-microwave-scrambled", ("card", ent)),
++ // ent,
++ // PopupType.Medium);
++
++ _adminLogger.Add(LogType.Action,
++ LogImpact.Medium,
++ $"{ToPrettyString(args.Microwave)} scrambled messages on {ToPrettyString(ent)}");
++ }
++
++ Dirty(ent);
++ }
++
++ private void ScrambleMessages(NanoChatCardComponent component)
++ {
++ foreach (var (recipientNumber, messages) in component.Messages)
++ {
++ for (var i = 0; i < messages.Count; i++)
++ {
++ // 50% chance to scramble each message
++ if (!_random.Prob(0.5f))
++ continue;
++
++ var message = messages[i];
++ message.Content = ScrambleText(message.Content);
++ messages[i] = message;
++ }
++
++ // 25% chance to reassign the conversation to a random recipient
++ if (_random.Prob(0.25f) && component.Recipients.Count > 0)
++ {
++ var newRecipient = _random.Pick(component.Recipients.Keys.ToList());
++ if (newRecipient == recipientNumber)
++ continue;
++
++ if (!component.Messages.ContainsKey(newRecipient))
++ component.Messages[newRecipient] = new List();
++
++ component.Messages[newRecipient].AddRange(messages);
++ component.Messages[recipientNumber].Clear();
++ }
++ }
++ }
++
++ private string ScrambleText(string text)
++ {
++ var chars = text.ToCharArray();
++ var n = chars.Length;
++
++ // Fisher-Yates shuffle of characters
++ while (n > 1)
++ {
++ n--;
++ var k = _random.Next(n + 1);
++ (chars[k], chars[n]) = (chars[n], chars[k]);
++ }
++
++ return new string(chars);
++ }
++
++ private void OnCardInit(Entity ent, ref MapInitEvent args)
++ {
++ if (ent.Comp.Number != null)
++ return;
++
++ // Assign a random number
++ _name.GenerateUniqueName(ent, _nameIdentifierGroup, out var number);
++ ent.Comp.Number = (uint)number;
++ Dirty(ent);
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Shared/Access/SharedAgentIDCardSystem.cs b/Content.Shared/Access/SharedAgentIDCardSystem.cs
+index aefd413de8b..12c40982ef4 100644
+--- a/Content.Shared/Access/SharedAgentIDCardSystem.cs
++++ b/Content.Shared/Access/SharedAgentIDCardSystem.cs
+@@ -28,12 +28,26 @@ public sealed class AgentIDCardBoundUserInterfaceState : BoundUserInterfaceState
+ public string CurrentName { get; }
+ public string CurrentJob { get; }
+ public string CurrentJobIconId { get; }
++ public uint? CurrentNumber { get; } // Corvax-Next-PDAChat
+
+- public AgentIDCardBoundUserInterfaceState(string currentName, string currentJob, string currentJobIconId)
++ public AgentIDCardBoundUserInterfaceState(string currentName, string currentJob, string currentJobIconId, uint? currentNumber = null) // Corvax-Next-PDAChat - Added currentNumber
+ {
+ CurrentName = currentName;
+ CurrentJob = currentJob;
+ CurrentJobIconId = currentJobIconId;
++ CurrentNumber = currentNumber; // Corvax-Next-PDAChat
++ }
++ }
++
++ // Corvax-Next-PDAChat - Add number change message
++ [Serializable, NetSerializable]
++ public sealed class AgentIDCardNumberChangedMessage : BoundUserInterfaceMessage
++ {
++ public uint Number { get; }
++
++ public AgentIDCardNumberChangedMessage(uint number)
++ {
++ Number = number;
+ }
+ }
+
+diff --git a/Content.Shared/CartridgeLoader/Cartridges/LogProbeUiState.cs b/Content.Shared/CartridgeLoader/Cartridges/LogProbeUiState.cs
+index 9dc507b7e51..fbe9ee3fc4b 100644
+--- a/Content.Shared/CartridgeLoader/Cartridges/LogProbeUiState.cs
++++ b/Content.Shared/CartridgeLoader/Cartridges/LogProbeUiState.cs
+@@ -1,4 +1,5 @@
+-using Robust.Shared.Serialization;
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Robust.Shared.Serialization;
+
+ namespace Content.Shared.CartridgeLoader.Cartridges;
+
+@@ -10,9 +11,15 @@ public sealed class LogProbeUiState : BoundUserInterfaceState
+ ///
+ public List PulledLogs;
+
+- public LogProbeUiState(List pulledLogs)
++ ///
++ /// Corvax-Next-PDAChat: The NanoChat data if a card was scanned, null otherwise
++ ///
++ public NanoChatData? NanoChatData { get; }
++
++ public LogProbeUiState(List pulledLogs, NanoChatData? nanoChatData = null) // Corvax-Next-PDAChat - NanoChat support
+ {
+ PulledLogs = pulledLogs;
++ NanoChatData = nanoChatData; // Corvax-Next-PDAChat
+ }
+ }
+
+diff --git a/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiMessageEvent.cs b/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiMessageEvent.cs
+new file mode 100644
+index 00000000000..6fd5e47b29e
+--- /dev/null
++++ b/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiMessageEvent.cs
+@@ -0,0 +1,166 @@
++using Content.Shared.CartridgeLoader;
++using Robust.Shared.Serialization;
++
++namespace Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++
++[Serializable, NetSerializable]
++public sealed class NanoChatUiMessageEvent : CartridgeMessageEvent
++{
++ ///
++ /// The type of UI message being sent.
++ ///
++ public readonly NanoChatUiMessageType Type;
++
++ ///
++ /// The recipient's NanoChat number, if applicable.
++ ///
++ public readonly uint? RecipientNumber;
++
++ ///
++ /// The content of the message or name for new chats.
++ ///
++ public readonly string? Content;
++
++ ///
++ /// The recipient's job title when creating a new chat.
++ ///
++ public readonly string? RecipientJob;
++
++ ///
++ /// Creates a new NanoChat UI message event.
++ ///
++ /// The type of message being sent
++ /// Optional recipient number for the message
++ /// Optional content of the message
++ /// Optional job title for new chat creation
++ public NanoChatUiMessageEvent(NanoChatUiMessageType type,
++ uint? recipientNumber = null,
++ string? content = null,
++ string? recipientJob = null)
++ {
++ Type = type;
++ RecipientNumber = recipientNumber;
++ Content = content;
++ RecipientJob = recipientJob;
++ }
++}
++
++[Serializable, NetSerializable]
++public enum NanoChatUiMessageType : byte
++{
++ NewChat,
++ SelectChat,
++ CloseChat,
++ SendMessage,
++ DeleteChat,
++ ToggleMute,
++}
++
++// putting this here because i can
++[Serializable, NetSerializable, DataRecord]
++public struct NanoChatRecipient
++{
++ ///
++ /// The recipient's unique NanoChat number.
++ ///
++ public uint Number;
++
++ ///
++ /// The recipient's display name, typically from their ID card.
++ ///
++ public string Name;
++
++ ///
++ /// The recipient's job title, if available.
++ ///
++ public string? JobTitle;
++
++ ///
++ /// Whether this recipient has unread messages.
++ ///
++ public bool HasUnread;
++
++ ///
++ /// Creates a new NanoChat recipient.
++ ///
++ /// The recipient's NanoChat number
++ /// The recipient's display name
++ /// Optional job title for the recipient
++ /// Whether there are unread messages from this recipient
++ public NanoChatRecipient(uint number, string name, string? jobTitle = null, bool hasUnread = false)
++ {
++ Number = number;
++ Name = name;
++ JobTitle = jobTitle;
++ HasUnread = hasUnread;
++ }
++}
++
++[Serializable, NetSerializable, DataRecord]
++public struct NanoChatMessage
++{
++ ///
++ /// When the message was sent.
++ ///
++ public TimeSpan Timestamp;
++
++ ///
++ /// The content of the message.
++ ///
++ public string Content;
++
++ ///
++ /// The NanoChat number of the sender.
++ ///
++ public uint SenderId;
++
++ ///
++ /// Whether the message failed to deliver to the recipient.
++ /// This can happen if the recipient is out of range or if there's no active telecomms server.
++ ///
++ public bool DeliveryFailed;
++
++ ///
++ /// Creates a new NanoChat message.
++ ///
++ /// When the message was sent
++ /// The content of the message
++ /// The sender's NanoChat number
++ /// Whether delivery to the recipient failed
++ public NanoChatMessage(TimeSpan timestamp, string content, uint senderId, bool deliveryFailed = false)
++ {
++ Timestamp = timestamp;
++ Content = content;
++ SenderId = senderId;
++ DeliveryFailed = deliveryFailed;
++ }
++}
++
++///
++/// NanoChat log data struct
++///
++/// Used by the LogProbe
++[Serializable, NetSerializable, DataRecord]
++public readonly struct NanoChatData(
++ Dictionary recipients,
++ Dictionary> messages,
++ uint? cardNumber,
++ NetEntity card)
++{
++ public Dictionary Recipients { get; } = recipients;
++ public Dictionary> Messages { get; } = messages;
++ public uint? CardNumber { get; } = cardNumber;
++ public NetEntity Card { get; } = card;
++}
++
++///
++/// Raised on the NanoChat card whenever a recipient gets added
++///
++[ByRefEvent]
++public readonly record struct NanoChatRecipientUpdatedEvent(EntityUid CardUid);
++
++///
++/// Raised on the NanoChat card whenever it receives or tries sending a messsage
++///
++[ByRefEvent]
++public readonly record struct NanoChatMessageReceivedEvent(EntityUid CardUid);
+\ No newline at end of file
+diff --git a/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiState.cs b/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiState.cs
+new file mode 100644
+index 00000000000..adc434b2b1a
+--- /dev/null
++++ b/Content.Shared/_CorvaxNext/CartridgeLoader/Cartridges/NanoChatUiState.cs
+@@ -0,0 +1,30 @@
++using Robust.Shared.Serialization;
++
++namespace Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++
++[Serializable, NetSerializable]
++public sealed class NanoChatUiState : BoundUserInterfaceState
++{
++ public readonly Dictionary Recipients = new();
++ public readonly Dictionary> Messages = new();
++ public readonly uint? CurrentChat;
++ public readonly uint OwnNumber;
++ public readonly int MaxRecipients;
++ public readonly bool NotificationsMuted;
++
++ public NanoChatUiState(
++ Dictionary recipients,
++ Dictionary> messages,
++ uint? currentChat,
++ uint ownNumber,
++ int maxRecipients,
++ bool notificationsMuted)
++ {
++ Recipients = recipients;
++ Messages = messages;
++ CurrentChat = currentChat;
++ OwnNumber = ownNumber;
++ MaxRecipients = maxRecipients;
++ NotificationsMuted = notificationsMuted;
++ }
++}
+\ No newline at end of file
+diff --git a/Content.Shared/_CorvaxNext/NanoChat/NanoChatCardComponent.cs b/Content.Shared/_CorvaxNext/NanoChat/NanoChatCardComponent.cs
+new file mode 100644
+index 00000000000..17e2594878d
+--- /dev/null
++++ b/Content.Shared/_CorvaxNext/NanoChat/NanoChatCardComponent.cs
+@@ -0,0 +1,52 @@
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Robust.Shared.GameStates;
++using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
++
++namespace Content.Shared._CorvaxNext.NanoChat;
++
++[RegisterComponent, NetworkedComponent, Access(typeof(SharedNanoChatSystem))]
++[AutoGenerateComponentPause, AutoGenerateComponentState]
++public sealed partial class NanoChatCardComponent : Component
++{
++ ///
++ /// The number assigned to this card.
++ ///
++ [DataField, AutoNetworkedField]
++ public uint? Number;
++
++ ///
++ /// All chat recipients stored on this card.
++ ///
++ [DataField]
++ public Dictionary Recipients = new();
++
++ ///
++ /// All messages stored on this card, keyed by recipient number.
++ ///
++ [DataField]
++ public Dictionary> Messages = new();
++
++ ///
++ /// The currently selected chat recipient number.
++ ///
++ [DataField]
++ public uint? CurrentChat;
++
++ ///
++ /// The maximum amount of recipients this card supports.
++ ///
++ [DataField]
++ public int MaxRecipients = 50;
++
++ ///
++ /// Last time a message was sent, for rate limiting.
++ ///
++ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField]
++ public TimeSpan LastMessageTime; // TODO: actually use this, compare against actor and not the card
++
++ ///
++ /// Whether to send notifications.
++ ///
++ [DataField]
++ public bool NotificationsMuted;
++}
+\ No newline at end of file
+diff --git a/Content.Shared/_CorvaxNext/NanoChat/SharedNanoChatSystem.cs b/Content.Shared/_CorvaxNext/NanoChat/SharedNanoChatSystem.cs
+new file mode 100644
+index 00000000000..f0f42059305
+--- /dev/null
++++ b/Content.Shared/_CorvaxNext/NanoChat/SharedNanoChatSystem.cs
+@@ -0,0 +1,273 @@
++using Content.Shared._CorvaxNext.CartridgeLoader.Cartridges;
++using Content.Shared.Examine;
++using Robust.Shared.Timing;
++
++namespace Content.Shared._CorvaxNext.NanoChat;
++
++///
++/// Base system for NanoChat functionality shared between client and server.
++///
++public abstract class SharedNanoChatSystem : EntitySystem
++{
++ [Dependency] private readonly IGameTiming _timing = default!;
++
++ public override void Initialize()
++ {
++ base.Initialize();
++ SubscribeLocalEvent(OnExamined);
++ }
++
++ private void OnExamined(Entity ent, ref ExaminedEvent args)
++ {
++ if (!args.IsInDetailsRange)
++ return;
++
++ if (ent.Comp.Number == null)
++ {
++ args.PushMarkup(Loc.GetString("nanochat-card-examine-no-number"));
++ return;
++ }
++
++ args.PushMarkup(Loc.GetString("nanochat-card-examine-number", ("number", $"{ent.Comp.Number:D4}")));
++ }
++
++ #region Public API Methods
++
++ ///
++ /// Gets the NanoChat number for a card.
++ ///
++ public uint? GetNumber(Entity card)
++ {
++ if (!Resolve(card, ref card.Comp))
++ return null;
++
++ return card.Comp.Number;
++ }
++
++ ///
++ /// Sets the NanoChat number for a card.
++ ///
++ public void SetNumber(Entity card, uint number)
++ {
++ if (!Resolve(card, ref card.Comp))
++ return;
++
++ card.Comp.Number = number;
++ Dirty(card);
++ }
++
++ ///
++ /// Gets the recipients dictionary from a card.
++ ///
++ public IReadOnlyDictionary GetRecipients(Entity card)
++ {
++ if (!Resolve(card, ref card.Comp))
++ return new Dictionary();
++
++ return card.Comp.Recipients;
++ }
++
++ ///
++ /// Gets the messages dictionary from a card.
++ ///
++ public IReadOnlyDictionary> GetMessages(Entity card)
++ {
++ if (!Resolve(card, ref card.Comp))
++ return new Dictionary>();
++
++ return card.Comp.Messages;
++ }
++
++ ///
++ /// Sets a specific recipient in the card.
++ ///
++ public void SetRecipient(Entity card, uint number, NanoChatRecipient recipient)
++ {
++ if (!Resolve(card, ref card.Comp))
++ return;
++
++ card.Comp.Recipients[number] = recipient;
++ Dirty(card);
++ }
++
++ ///
++ /// Gets a specific recipient from the card.
++ ///
++ public NanoChatRecipient? GetRecipient(Entity card, uint number)
++ {
++ if (!Resolve(card, ref card.Comp) || !card.Comp.Recipients.TryGetValue(number, out var recipient))
++ return null;
++
++ return recipient;
++ }
++
++ ///
++ /// Gets all messages for a specific recipient.
++ ///
++ public List? GetMessagesForRecipient(Entity card, uint recipientNumber)
++ {
++ if (!Resolve(card, ref card.Comp) || !card.Comp.Messages.TryGetValue(recipientNumber, out var messages))
++ return null;
++
++ return new List(messages);
++ }
++
++ ///
++ /// Adds a message to a recipient's conversation.
++ ///
++ public void AddMessage(Entity card, uint recipientNumber, NanoChatMessage message)
++ {
++ if (!Resolve(card, ref card.Comp))
++ return;
++
++ if (!card.Comp.Messages.TryGetValue(recipientNumber, out var messages))
++ {
++ messages = new List();
++ card.Comp.Messages[recipientNumber] = messages;
++ }
++
++ messages.Add(message);
++ card.Comp.LastMessageTime = _timing.CurTime;
++ Dirty(card);
++ }
++
++ ///
++ /// Gets the currently selected chat recipient.
++ ///
++ public uint? GetCurrentChat(Entity card)
++ {
++ if (!Resolve(card, ref card.Comp))
++ return null;
++
++ return card.Comp.CurrentChat;
++ }
++
++ ///