Compare commits

...
34 Commits
Author SHA1 Message Date
Tobias Berger c1cc812cd3 Better admin logging for nanochat 2025-12-14 21:09:23 +01:00
Tobias Berger e273a1fa3a Add some more nanochat admin logging 2025-12-14 20:21:00 +01:00
Tobias Berger e297168eef Fix emoji some more 2025-12-12 10:43:22 +01:00
Tobias Berger 984a5384a5 Revert "Show numbers in message sender label"
This reverts commit 9e617db031.
2025-12-11 16:51:02 +01:00
Tobias Berger b37476785c Only lookup recipients inside a PDA 2025-12-11 16:40:32 +01:00
Tobias Berger 997d5b691f Disable NanoChat UI when no ID inserted 2025-12-11 15:40:48 +01:00
Tobias Berger 63e37a76bf Contact list update on create 2025-12-11 15:14:39 +01:00
Tobias Berger 66b8a7f74b Emoji ID for debugging 2025-12-11 14:19:12 +01:00
Tobias Berger 35b07aeacd Fix non-localized string 2025-12-11 14:05:49 +01:00
Tobias Berger fe2a30456f Escape nanochat messages 2025-12-11 14:04:25 +01:00
Tobias Berger e23d6f3ba7 Sort instead of Order 2025-12-11 12:44:50 +01:00
Tobias Berger f167435cfc Fix doubled sender number 2025-12-11 12:02:31 +01:00
Tobias Berger 3c93bfc943 Move 'view member' button 2025-12-11 12:02:12 +01:00
Tobias Berger 9e617db031 Show numbers in message sender label 2025-12-11 03:13:05 +01:00
Tobias Berger 258dd35754 Improve emoji insertion UX 2025-12-11 03:04:26 +01:00
Tobias Berger 126ee2ac35 Improve padding of NanoChat Message list 2025-12-11 03:04:15 +01:00
Tobias Berger 188e895d6a Clean up Funky commments in _DV 2025-12-11 03:03:36 +01:00
Tobias Berger 08ecb92fdc Clear emoji cache when prototypes reload 2025-12-11 02:51:38 +01:00
Tobias Berger 683b9db3d9 Touch up NanoChatRecipient 2025-12-11 02:50:50 +01:00
Tobias Berger 880c1bed31 Fix Emoji alignment 2025-12-11 02:48:22 +01:00
Tobias Berger aba4972866 Fix emoji 2025-12-10 19:51:01 +01:00
Tobias Berger 2758b202fa Remove redundant subscription 2025-12-10 18:40:32 +01:00
Tobias Berger f797d0bf8d Cherrypick fixes 2025-12-10 18:07:31 +01:00
Tobias Berger 5602dc5089 optimize nanochat emojis 2025-12-10 18:01:02 +01:00
Evaisa 19722eeec7 Fallback for listing sender names.
Sender names now show up if they are in your contacts, even if they do not have List Number enabled.
If their name can not be obtained then it will show "Unknown#0000" rather than just the nanochat number.

(cherry picked from commit 90775680d8b7b2d95636dd1027e56d48118e55e1)
2025-12-10 17:55:47 +01:00
Evaisa fc558e41ea compiles properly now
(cherry picked from commit a000c7b35937bbfc94909cf2da092e90a27161f1)
2025-12-10 17:55:47 +01:00
Evaisa daf070f876 Fix issues with nanochat
When pAI and PDA owner open nanochat at the same time it should no longer break.
"Invite To Group" list now shows accurate/live data instead of outdated cached recipient data
Popups should no longer stay open when switching apps or closing the PDA.
Group chats should now properly show when messages failed to deliver.
Invite menus now show job titles.
Invite menus are now sorted by department.

(cherry picked from commit f23580452a0a43cc105e5642ead35999a820c675)
2025-12-10 17:55:44 +01:00
funkystationbot 2cd02f1ea8 chore: Automatically update REUSE headers
(cherry picked from commit 661e8136af4c7b31d657531b667043e35b84190a)
2025-12-10 17:51:17 +01:00
Evaisa e1ce26896e Emoji definitions are done in yaml instead.
Emojis are now defined through yaml, hope I did not miss any.

(cherry picked from commit 48936a63248e7e76855157a36ff6214faa4c6051)
2025-12-10 17:51:17 +01:00
funkystationbot bd2245eee3 chore: Automatically update REUSE headers
(cherry picked from commit 84d2cc3a1f31878e5d18ebde63928d63f3b4dd95)
2025-12-10 17:51:13 +01:00
Evaisa 0b03791672 fixes
yippee

(cherry picked from commit 2d847cd7df0780f439cec6782414c86d859b0a56)
2025-12-10 17:50:35 +01:00
Evaisa 7250214259 Fix small bug
- Ownership did not get transferred when clicking the delete button in a group chat.

(cherry picked from commit d7ccc986155221dbbfb948ed0694347e72c50369)
2025-12-10 17:50:15 +01:00
Evaisa 46c3b5079d Fix some emojis not working because of RSI stuff & Bug with regular chats
- Regular chats were received as the wrong person.
- Certain emotes didn't load outside of debug builds seemingly.

(cherry picked from commit deb2b584c77b1715a1adb1327c52f80046f7c79a)
2025-12-10 17:50:15 +01:00
Evaisa 3e618923ea Added group chats and emojis
- You can now create group chats in NanoChat
- You can also send your meta friends your favourite :godo: emote.

(cherry picked from commit eedb378b9f33033faf4e9dd139f4b2366dcf0632)
2025-12-10 17:50:02 +01:00
52 changed files with 2550 additions and 84 deletions
@@ -59,6 +59,9 @@ public abstract class CartridgeLoaderBoundUserInterface : BoundUserInterface
SendCartridgeUiReadyEvent(_activeProgram.Value);
}
if (_activeCartridgeUI is IDisposable disposableUi) // Funky: Handle cleanup for popups and such.
disposableUi.Dispose();
_activeCartridgeUI = ui;
_activeUiFragment?.Dispose();
_activeUiFragment = control;
@@ -121,8 +124,13 @@ public abstract class CartridgeLoaderBoundUserInterface : BoundUserInterface
{
base.Dispose(disposing);
if (disposing)
if (disposing) // Funky: Handle cleanup for popups and such.
{
if (_activeCartridgeUI is IDisposable disposableUi)
disposableUi.Dispose();
_activeUiFragment?.Dispose();
}
}
protected CartridgeComponent? RetrieveCartridgeComponent(EntityUid? cartridgeUid)
@@ -26,6 +26,16 @@
</PanelContainer.PanelOverride>
</PanelContainer>
<!-- Group chat icon -->
<TextureRect Name="GroupIcon"
TexturePath="/Textures/_Funkystation/Interface/NanoChat/members.png"
MinSize="16 16"
MaxSize="16 16"
Stretch="KeepAspectCentered"
VerticalAlignment="Center"
Margin="4 0 6 0"
Visible="False" />
<!-- Text container -->
<BoxContainer Orientation="Vertical"
HorizontalExpand="True"
@@ -44,6 +44,7 @@ public sealed partial class NanoChatEntry : BoxContainer
JobLabel.Text = FormattedMessage.EscapeText(SharedNanoChatSystem.Truncate(recipient.JobTitle ?? string.Empty, _maxIdJobLength));
JobLabel.Visible = !string.IsNullOrWhiteSpace(JobLabel.Text);
UnreadIndicator.Visible = recipient.HasUnread;
GroupIcon.Visible = recipient.IsGroup;
ChatButton.ModulateSelfOverride = isSelected ? NanoChatMessageBubble.OwnMessageColor : null;
}
@@ -1,9 +1,12 @@
using System.Linq;
using System.Numerics;
using Content.Shared._DV.CartridgeLoader.Cartridges;
using Content.Shared.Roles;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Client._DV.CartridgeLoader.Cartridges;
@@ -26,12 +29,67 @@ public sealed partial class NanoChatLookupView : PanelContainer
return;
}
for (var idx = 0; idx < contacts.Count; idx++)
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
var departmentGroups = new Dictionary<DepartmentPrototype, List<NanoChatRecipient>>();
var noDepartmentContacts = new List<NanoChatRecipient>();
foreach (var contact in contacts)
{
var contact = contacts[idx];
var isEvenRow = idx % 2 == 0;
var contactControl = new ContactContainer(contact, state, isEvenRow, OnStartChat);
ContactsList.AddChild(contactControl);
if (!string.IsNullOrWhiteSpace(contact.Department) &&
prototypeManager.TryIndex<DepartmentPrototype>(contact.Department, out var dept))
{
departmentGroups.GetOrNew(dept).Add(contact);
}
else
{
noDepartmentContacts.Add(contact);
}
}
var rowIndex = 0;
foreach (var (department, departmentContacts) in departmentGroups.OrderBy(entry => entry.Key, DepartmentUIComparer.Instance))
{
departmentContacts.Sort();
// Add department header
ContactsList.AddChild(new Label
{
Text = Loc.GetString(department.Name),
StyleClasses = { "LabelBig" },
Margin = new Thickness(0, rowIndex == 0 ? 0 : 8, 0, 2)
});
foreach (var contact in departmentContacts)
{
var isEvenRow = rowIndex % 2 == 0;
var contactControl = new ContactContainer(contact, state, isEvenRow, OnStartChat);
ContactsList.AddChild(contactControl);
rowIndex++;
}
}
// Add contacts without departments at the end
if (noDepartmentContacts.Count > 0)
{
noDepartmentContacts.Sort();
if (departmentGroups.Count > 0)
{
ContactsList.AddChild(new Label
{
Text = Loc.GetString("nano-chat-other-department"),
StyleClasses = { "LabelBig" },
Margin = new Thickness(0, 8, 0, 2)
});
}
foreach (var contact in noDepartmentContacts)
{
var isEvenRow = rowIndex % 2 == 0;
var contactControl = new ContactContainer(contact, state, isEvenRow, OnStartChat);
ContactsList.AddChild(contactControl);
rowIndex++;
}
}
}
@@ -42,9 +100,13 @@ public sealed partial class NanoChatLookupView : PanelContainer
HorizontalExpand = true;
StyleClasses.Add(isEvenRow ? "PanelBackgroundBaseDark" : "PanelBackgroundLight");
var displayName = contact.Name;
if (!string.IsNullOrWhiteSpace(contact.JobTitle))
displayName = $"{contact.Name} ({contact.JobTitle})";
var nameLabel = new Label()
{
Text = contact.Name,
Text = displayName,
HorizontalAlignment = HAlignment.Left,
HorizontalExpand = true
};
@@ -1,5 +1,6 @@
<cartridges:NanoChatMessageBubble
xmlns="https://spacestation14.io"
xmlns:nanochatui="clr-namespace:Content.Client._DV.NanoChat.UI"
xmlns:cartridges="clr-namespace:Content.Client._DV.CartridgeLoader.Cartridges"
xmlns:graphics="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
HorizontalExpand="True">
@@ -7,31 +8,34 @@
<BoxContainer Name="MessageContainer"
Orientation="Horizontal"
HorizontalExpand="True">
<!-- Left spacer for other's messages -->
<Control Name="LeftSpacer"
MinSize="12 0" />
<!-- Left spacer for other's messages -->
<Control MinSize="12 0" Name="LeftSpacer" />
<!-- Message panel -->
<BoxContainer Name="MessageBox"
Orientation="Vertical"
MaxWidth="320"
HorizontalExpand="True">
<!-- Sender name -->
<Label HorizontalAlignment="Left"
Margin="10,0,10,2"
Name="SenderNameLabel"
StyleClasses="LabelSmall"
Visible="False" />
<PanelContainer Name="MessagePanel"
MaxWidth="320"
HorizontalExpand="True">
<PanelContainer.PanelOverride>
<graphics:StyleBoxFlat
ContentMarginLeftOverride="10"
ContentMarginRightOverride="10"
ContentMarginTopOverride="6"
ContentMarginBottomOverride="6"
BorderThickness="1">
<graphics:StyleBoxFlat BorderThickness="1">
<!-- Colors set in code based on message sender -->
</graphics:StyleBoxFlat>
</PanelContainer.PanelOverride>
<RichTextLabel Name="MessageText"
HorizontalExpand="True" />
HorizontalExpand="True"
Margin="10,6,10,6" />
</PanelContainer>
<!-- Delivery failed text -->
@@ -1,15 +1,19 @@
using System.Text.RegularExpressions;
using Content.Shared._DV.CartridgeLoader.Cartridges;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Utility;
using Content.Client._Funkystation.NanoChat;
namespace Content.Client._DV.CartridgeLoader.Cartridges;
[GenerateTypedNameReferences]
public sealed partial class NanoChatMessageBubble : BoxContainer
{
private static readonly Regex EmojiRegex = new(@":(\w+):", RegexOptions.Compiled);
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
@@ -21,7 +25,7 @@ public sealed partial class NanoChatMessageBubble : BoxContainer
RobustXamlLoader.Load(this);
}
public void SetMessage(NanoChatMessage message, bool isOwnMessage)
public void SetMessage(NanoChatMessage message, bool isOwnMessage, string? senderName = null, bool showSenderName = false)
{
if (MessagePanel.PanelOverride is not StyleBoxFlat)
return;
@@ -31,8 +35,20 @@ public sealed partial class NanoChatMessageBubble : BoxContainer
style.BackgroundColor = isOwnMessage ? OwnMessageColor : OtherMessageColor;
style.BorderColor = BorderColor;
// Set message content
MessageText.Text = FormattedMessage.EscapeText(message.Content);
var contentWithEmoji = EmojiRegex.Replace(FormattedMessage.EscapeText(message.Content), "[emoji=\"$1\"]");
MessageText.SetMessage(
FormattedMessage.FromMarkupPermissive(contentWithEmoji),
new[] { typeof(EmojiTag) }
);
SenderNameLabel.Visible = showSenderName && !string.IsNullOrEmpty(senderName);
if (SenderNameLabel.Visible)
{
SenderNameLabel.Text = senderName;
SenderNameLabel.Modulate = TextColor;
}
MessageText.Modulate = TextColor;
// Show delivery failed text if needed (only for own messages)
@@ -5,7 +5,7 @@ using Robust.Client.UserInterface;
namespace Content.Client._DV.CartridgeLoader.Cartridges;
public sealed partial class NanoChatUi : UIFragment
public sealed partial class NanoChatUi : UIFragment, IDisposable
{
private NanoChatUiFragment? _fragment;
@@ -30,6 +30,12 @@ public sealed partial class NanoChatUi : UIFragment
_fragment?.UpdateState(cast);
}
public void Dispose()
{
// Close all popups when switching apps or closing the PDA
_fragment?.CloseAllPopups();
}
private static void SendNanoChatUiMessage(NanoChatUiMessageType type,
uint? number,
string? content,
@@ -60,6 +60,17 @@
Margin="0 0 4 0"
StyleClasses="OpenBoth"
ToolTip="{Loc nano-chat-new-chat}" />
<Button Name="CreateGroupChatButton"
MinSize="32 32"
MaxSize="32 32"
Margin="0 0 4 0"
StyleClasses="OpenBoth"
ToolTip="{Loc nano-chat-create-group}">
<TextureRect StyleClasses="ButtonSquare"
TexturePath="/Textures/_Funkystation/Interface/NanoChat/members.png"
Stretch="KeepAspectCentered"
MinSize="18 18" />
</Button>
<Button Name="LookupButton"
MaxSize="32 32"
StyleClasses="OpenBoth"
@@ -84,12 +95,20 @@
</BoxContainer>
</controls:StripeBack>
<Label Name="NoIdLabel"
Text="{Loc 'nano-chat-no-id-card'}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
VerticalExpand="True"
HorizontalExpand="True"
Visible="True" />
<!-- Main content split -->
<BoxContainer Name="ChatView"
Orientation="Horizontal"
VerticalExpand="True"
HorizontalExpand="True"
Margin="0 5 0 0">
Margin="0 5 0 0"
Visible="False">
<!-- Left panel: Chat list -->
<PanelContainer StyleClasses="AngleRect"
VerticalExpand="True"
@@ -167,6 +186,18 @@
Stretch="KeepAspectCentered"
MinSize="18 18" />
</Button>
<!-- View Members should take place of edit button in group chats, to avoid layout shift when moving between normal and group -->
<Button Name="ViewMembersButton"
MaxSize="32 32"
Visible="False"
StyleClasses="OpenBoth"
Margin="0 0 4 0"
ToolTip="{Loc nano-chat-view-members}">
<TextureRect StyleClasses="ButtonSquare"
TexturePath="/Textures/_Funkystation/Interface/NanoChat/members.png"
Stretch="KeepAspectCentered"
MinSize="18 18" />
</Button>
<Button Name="DeleteChatButton"
MaxSize="32 32"
Visible="False"
@@ -197,11 +228,34 @@
MinSize="18 18" />
</Control>
</Button>
<Button Name="InviteToGroupButton"
MaxSize="32 32"
Visible="False"
StyleClasses="OpenBoth"
Margin="0 0 4 0"
ToolTip="{Loc nano-chat-invite-to-group}">
<TextureRect StyleClasses="ButtonSquare"
TexturePath="/Textures/_Funkystation/Interface/NanoChat/invite.png"
Stretch="KeepAspectCentered"
MinSize="18 18" />
</Button>
<!-- Input row -->
<LineEdit Name="MessageInput"
PlaceHolder="{Loc nano-chat-message-placeholder}"
HorizontalExpand="True"
StyleClasses="OpenRight" />
<!-- Emoji Picker -->
<Button Name="EmojiButton"
MinSize="32 32"
MaxSize="32 32"
StyleClasses="OpenBoth"
Margin="4 0 0 0"
ToolTip="{Loc nano-chat-emoji-picker}">
<TextureRect StyleClasses="ButtonSquare"
TexturePath="/Textures/Interface/Emotes/laugh.png"
Stretch="KeepAspectCentered"
MinSize="24 24" />
</Button>
<Button Name="SendButton"
MinSize="32 32"
Disabled="True"
@@ -1,7 +1,18 @@
// SPDX-FileCopyrightText: 2024 Skubman <ba.fallaria@gmail.com>
// SPDX-FileCopyrightText: 2024 Tadeo <td12233a@gmail.com>
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
// SPDX-FileCopyrightText: 2025 Icepick <122653407+Icepicked@users.noreply.github.com>
// SPDX-FileCopyrightText: 2025 corresp0nd <46357632+corresp0nd@users.noreply.github.com>
// SPDX-FileCopyrightText: 2025 taydeo <td12233a@gmail.com>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
using System.Linq;
using System.Numerics;
using Content.Shared._DV.CartridgeLoader.Cartridges;
using Content.Shared.CCVar;
using Content.Client._Funkystation.NanoChat;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
@@ -18,10 +29,14 @@ public sealed partial class NanoChatUiFragment : BoxContainer
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
private int _maxNameLength;
private int _maxIdJobLength;
private readonly int _maxNameLength;
private readonly int _maxIdJobLength;
private readonly NewChatPopup _newChatPopup;
private readonly EditChatPopup _editChatPopup;
private readonly CreateGroupChatPopup _createGroupChatPopup;
private readonly InviteToGroupPopup _inviteToGroupPopup;
private readonly GroupMembersPopup _groupMembersPopup;
private readonly EmojiPickerPopup _emojiPickerPopup;
private uint? _currentChat;
private uint? _pendingChat;
private uint _ownNumber;
@@ -30,6 +45,7 @@ public sealed partial class NanoChatUiFragment : BoxContainer
private Dictionary<uint, NanoChatRecipient> _recipients = [];
private Dictionary<uint, List<NanoChatMessage>> _messages = [];
private HashSet<uint> _mutedChats = [];
private List<NanoChatRecipient>? _contacts; // Used in order to show names for people not in recipients
public event Action<NanoChatUiMessageType, uint?, string?, string?>? OnMessageSent;
@@ -43,6 +59,10 @@ public sealed partial class NanoChatUiFragment : BoxContainer
_newChatPopup = new(_maxNameLength, _maxIdJobLength);
_editChatPopup = new(_maxNameLength, _maxIdJobLength);
_createGroupChatPopup = new(_maxNameLength);
_inviteToGroupPopup = new();
_groupMembersPopup = new();
_emojiPickerPopup = new();
SetupEventHandlers();
}
@@ -58,12 +78,57 @@ public sealed partial class NanoChatUiFragment : BoxContainer
OnMessageSent?.Invoke(NanoChatUiMessageType.EditChat, number, name, job);
};
_createGroupChatPopup.OnGroupCreated += (name) =>
{
OnMessageSent?.Invoke(NanoChatUiMessageType.CreateGroupChat, null, name, null);
};
_inviteToGroupPopup.OnInvite += (number) =>
{
if (_currentChat is not uint currentChat)
return;
OnMessageSent?.Invoke(NanoChatUiMessageType.InviteToGroup, currentChat, number.ToString(), null);
};
_groupMembersPopup.OnKick += (number) =>
{
if (_currentChat is not uint currentChat)
return;
OnMessageSent?.Invoke(NanoChatUiMessageType.KickFromGroup, currentChat, number.ToString(), null);
};
_groupMembersPopup.OnAdmin += (number) =>
{
if (_currentChat is not uint currentChat)
return;
OnMessageSent?.Invoke(NanoChatUiMessageType.AdminUser, currentChat, number.ToString(), null);
};
_groupMembersPopup.OnDeadmin += (number) =>
{
if (_currentChat is not uint currentChat)
return;
OnMessageSent?.Invoke(NanoChatUiMessageType.DeadminUser, currentChat, number.ToString(), null);
};
_emojiPickerPopup.OnEmojiSelected += InsertEmoji;
NewChatButton.OnPressed += _ =>
{
_newChatPopup.ClearInputs();
_newChatPopup.OpenCentered();
};
CreateGroupChatButton.OnPressed += _ =>
{
_createGroupChatPopup.ClearInputs();
_createGroupChatPopup.OpenCentered();
};
MuteChatButton.OnPressed += _ =>
{
if (_currentChat is not uint currentChat)
@@ -134,8 +199,39 @@ public sealed partial class NanoChatUiFragment : BoxContainer
MessageInput.OnTextEntered += _ => SendMessage();
SendButton.OnPressed += _ => SendMessage();
EmojiButton.OnPressed += _ => OpenEmojiPicker();
EditChatButton.OnPressed += _ => BeginEditChat();
DeleteChatButton.OnPressed += _ => DeleteCurrentChat();
InviteToGroupButton.OnPressed += _ => OpenInvitePopup();
ViewMembersButton.OnPressed += _ => OpenMembersPopup();
}
private void OpenEmojiPicker()
{
// Close any existing emoji picker to prevent duplicates (shouldn't be happening anyway but it sure did)
if (_emojiPickerPopup.IsOpen)
_emojiPickerPopup.Close();
_emojiPickerPopup.OpenCentered();
}
private void InsertEmoji(string emoji)
{
MessageInput.InsertAtCursor(emoji);
MessageInput.GrabKeyboardFocus();
}
/// <summary>
/// Closes all open popups. Called when switching apps or closing the PDA.
/// </summary>
public void CloseAllPopups()
{
_emojiPickerPopup.Close();
_inviteToGroupPopup.Close();
_groupMembersPopup.Close();
_newChatPopup.Close();
_editChatPopup.Close();
_createGroupChatPopup.Close();
}
private void ToggleView()
@@ -156,7 +252,7 @@ public sealed partial class NanoChatUiFragment : BoxContainer
if (_recipients.Count == 0)
return;
var orderedRecipients = _recipients.OrderBy(r => r.Value.Name).Select(r => r.Key).ToArray();
var orderedRecipients = _recipients.OrderBy(r => r.Value).Select(r => r.Key).ToArray();
var currentChatIndex = (direction, _currentChat) switch
{
(CycleDirection.Up, null) => _recipients.Count,
@@ -252,6 +348,89 @@ public sealed partial class NanoChatUiFragment : BoxContainer
OnMessageSent?.Invoke(NanoChatUiMessageType.DeleteChat, activeChat, null, null);
}
private void OpenInvitePopup()
{
if (_currentChat is not uint currentChat ||
!_recipients.TryGetValue(currentChat, out var recipient) ||
!recipient.IsGroup)
return;
var availableContacts = _contacts ?? new List<NanoChatRecipient>();
var members = recipient.Members ?? new HashSet<uint>();
_inviteToGroupPopup.SetContacts(availableContacts, members, _ownNumber);
_inviteToGroupPopup.ClearSearch();
_inviteToGroupPopup.OpenCentered();
}
private void OpenMembersPopup()
{
if (_currentChat is not uint currentChat ||
!_recipients.TryGetValue(currentChat, out var recipient) ||
!recipient.IsGroup)
return;
var availableContacts = _contacts ?? new List<NanoChatRecipient>();
var members = recipient.Members ?? new HashSet<uint>();
var admins = recipient.Admins ?? new HashSet<uint>();
_groupMembersPopup.SetMembers(availableContacts, members, _ownNumber, recipient.CreatorId, admins);
_groupMembersPopup.OpenCentered();
}
private void RefreshOpenPopups()
{
// If popup is open, refresh it or close if no longer a member
if (_inviteToGroupPopup.IsOpen)
{
if (_currentChat is uint currentChat &&
_recipients.TryGetValue(currentChat, out var recipient) &&
recipient.IsGroup)
{
var members = recipient.Members ?? new HashSet<uint>();
if (!members.Contains(_ownNumber))
{
_inviteToGroupPopup.Close();
}
else
{
// Use the contacts list from the server (same as Look Up Numbers)
// Don't fall back to recipients as they contain outdated cached information
var availableContacts = _contacts ?? new List<NanoChatRecipient>();
_inviteToGroupPopup.SetContacts(availableContacts, members, _ownNumber);
}
}
else
{
_inviteToGroupPopup.Close();
}
}
if (_groupMembersPopup.IsOpen)
{
if (_currentChat is uint currentChat &&
_recipients.TryGetValue(currentChat, out var recipient) &&
recipient.IsGroup)
{
var members = recipient.Members ?? new HashSet<uint>();
if (!members.Contains(_ownNumber))
{
_groupMembersPopup.Close();
}
else
{
// Use the contacts list from the server (same as Look Up Numbers)
// Don't fall back to recipients as they contain outdated cached information
var availableContacts = _contacts ?? new List<NanoChatRecipient>();
var admins = recipient.Admins ?? new HashSet<uint>();
_groupMembersPopup.SetMembers(availableContacts, members, _ownNumber, recipient.CreatorId, admins);
}
}
else
{
_groupMembersPopup.Close();
}
}
}
private void BeginEditChat()
{
if (_currentChat is not uint currentChat)
@@ -294,17 +473,40 @@ public sealed partial class NanoChatUiFragment : BoxContainer
var activeChat = _pendingChat ?? _currentChat;
var hasActiveChat = activeChat != null;
var isGroupChat = false;
var canManageGroup = false;
NanoChatRecipient recipient = default;
var hasRecipient = false;
if (hasActiveChat && activeChat != null && _recipients.TryGetValue(activeChat.Value, out recipient))
{
hasRecipient = true;
isGroupChat = recipient.IsGroup;
canManageGroup = isGroupChat && (recipient.CreatorId == _ownNumber || recipient.Admins?.Contains(_ownNumber) == true);
}
// Update UI state
MessagesScroll.Visible = hasActiveChat;
CurrentChatName.Visible = !hasActiveChat;
MessageInputContainer.Visible = hasActiveChat;
DeleteChatButton.Visible = hasActiveChat;
EditChatButton.Visible = hasActiveChat;
EditChatButton.Visible = hasActiveChat && !isGroupChat;
DeleteChatButton.Disabled = !hasActiveChat;
if (activeChat != null && _recipients.TryGetValue(activeChat.Value, out var recipient))
InviteToGroupButton.Visible = canManageGroup;
ViewMembersButton.Visible = isGroupChat;
if (hasRecipient)
{
CurrentChatName.Text = recipient.Name + (string.IsNullOrEmpty(recipient.JobTitle) ? "" : $" ({recipient.JobTitle})");
if (isGroupChat)
{
CurrentChatName.Text = recipient.Name;
}
else
{
CurrentChatName.Text = recipient.Name + (string.IsNullOrEmpty(recipient.JobTitle) ? "" : $" ({recipient.JobTitle})");
}
}
else
{
@@ -321,14 +523,44 @@ public sealed partial class NanoChatUiFragment : BoxContainer
if (activeChat == null || !messages.TryGetValue(activeChat.Value, out var chatMessages))
return;
var isGroupChat = _recipients.TryGetValue(activeChat.Value, out var recipient) && recipient.IsGroup;
foreach (var message in chatMessages)
{
var messageBubble = new NanoChatMessageBubble();
messageBubble.SetMessage(message, message.SenderId == _ownNumber);
MessageList.AddChild(messageBubble);
var isOwnMessage = message.SenderId == _ownNumber;
// For group chats, show sender name for other people's messages
string? senderName = null;
if (isGroupChat && !isOwnMessage)
{
// Try to get sender name from contacts first, then recipients
if (_recipients.TryGetValue(message.SenderId, out var senderRecipient))
{
senderName = senderRecipient.Name;
}
// If not in recipients, try contacts list (station-wide directory)
if (senderName == null && _contacts != null)
{
var contact = _contacts.FirstOrDefault(c => c.Number == message.SenderId);
if (contact.Number != 0)
senderName = contact.Name;
}
// Fallback to "Unknown" with number if name not found
if (senderName == null)
{
senderName = $"{Loc.GetString("nano-chat-unknown-sender")}#{message.SenderId:D4}";
}
}
messageBubble.SetMessage(message, isOwnMessage, senderName, isGroupChat && !isOwnMessage);
// Add spacing between messages
MessageList.AddChild(new Control { MinSize = new Vector2(0, 4) });
if (MessageList.ChildCount > 0)
MessageList.AddChild(new Control { Name = "MessagePadding", MinSize = new Vector2(0, 4) });
MessageList.AddChild(messageBubble);
}
MessageList.InvalidateMeasure();
@@ -357,13 +589,41 @@ public sealed partial class NanoChatUiFragment : BoxContainer
ListNumberButton.Pressed = _listNumber;
}
private void CheckValidNumber()
{
if (_ownNumber == 0)
{
NoIdLabel.Visible = true;
ChatView.Visible = false;
LookupView.Visible = false;
MuteButton.Disabled = true;
NewChatButton.Disabled = true;
CreateGroupChatButton.Disabled = true;
LookupButton.Disabled = true;
ListNumberButton.Disabled = true;
}
else if (NoIdLabel.Visible)
{
NoIdLabel.Visible = false;
ToggleView();
MuteButton.Disabled = false;
NewChatButton.Disabled = false;
CreateGroupChatButton.Disabled = false;
LookupButton.Disabled = false;
ListNumberButton.Disabled = false;
}
}
public void UpdateState(NanoChatUiState state)
{
_ownNumber = state.OwnNumber;
_notificationsMuted = state.NotificationsMuted;
_listNumber = state.ListNumber;
_mutedChats = state.MutedChats;
OwnNumberLabel.Text = $"#{state.OwnNumber:D4}";
_contacts = state.Contacts;
OwnNumberLabel.Text = $"#{_ownNumber:D4}";
UpdateMuteButton();
UpdateListNumber();
@@ -387,10 +647,15 @@ public sealed partial class NanoChatUiFragment : BoxContainer
if (_pendingChat == null)
_currentChat = state.CurrentChat;
UpdateCurrentChat();
UpdateMuteChatButton();
// Update recipients first, then update UI that depends on it
UpdateChatList(state.Recipients);
UpdateMessages(state.Messages);
UpdateCurrentChat();
UpdateMuteChatButton();
LookupView.UpdateContactList(state);
CheckValidNumber();
RefreshOpenPopups();
}
}
+130
View File
@@ -0,0 +1,130 @@
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface;
namespace Content.Client._DV.NanoChat.UI;
/// <summary>
/// Extremely specialized version of <see cref="Robust.Client.UserInterface.Controls.TextureRect" /> designed for drawing Textures at fixed size with an additional offset
/// Circumvents the problem of not having "negative mergins" on TextureRect for manual alignment
/// This is fucking evil, please do not use this :3
/// </summary>
public sealed class EmojiControl : Control
{
public const string StylePropertyTexture = "texture";
public const string StylePropertyOffset = "offset";
private Texture? _texture;
private Vector2? _offset;
private string? _texturePath;
public string TexturePath
{
set
{
Texture = IoCManager.Resolve<IResourceCache>().GetResource<TextureResource>(value);
_texturePath = value;
}
}
protected override void OnThemeUpdated()
{
if (_texturePath != null) Texture = Theme.ResolveTexture(_texturePath);
base.OnThemeUpdated();
}
/// <summary>
/// The texture to draw.
/// </summary>
public Texture? Texture
{
get
{
if (_texture is null)
{
if (TryGetStyleProperty(StylePropertyTexture, out Texture? texture))
{
return texture;
}
}
return _texture;
}
set
{
var oldSize = _texture?.Size;
_texture = value;
if (value?.Size != oldSize)
{
InvalidateMeasure();
}
}
}
/// <summary>
/// Vertical offset of the texture
/// </summary>
public Vector2 Offset
{
get
{
if (_offset is null)
{
if (TryGetStyleProperty(StylePropertyOffset, out Vector2 offset))
return offset;
}
return _offset ?? Vector2.Zero;
}
set
{
var oldOffset = _offset;
_offset = value;
if (oldOffset != value)
{
InvalidateArrange();
}
}
}
protected override void ArrangeCore(UIBox2 finalRect)
{
finalRect.Left += Offset.X;
finalRect.Right += Offset.X;
finalRect.Top += Offset.Y;
finalRect.Bottom += Offset.Y;
base.ArrangeCore(finalRect);
}
protected override void Draw(DrawingHandleScreen handle)
{
var texture = Texture;
if (texture is null)
return;
handle.DrawTextureRect(texture, GetDrawDimensions());
}
private UIBox2 GetDrawDimensions()
{
var (texWidth, texHeight) = Texture?.Size ?? Vector2i.Zero;
var width = texWidth * (PixelSize.Y / texHeight);
var height = (float)PixelSize.Y;
if (width > PixelSize.X)
{
width = PixelSize.X;
height = texHeight * (PixelSize.X / texWidth);
}
var size = new Vector2(width, height);
var position = (PixelSize - size) / 2 + Offset;
return UIBox2.FromDimensions(position, size);
}
}
@@ -0,0 +1,38 @@
<!--
SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
-->
<DefaultWindow xmlns="https://spacestation14.io"
Title="{Loc nano-chat-create-group-title}"
MinSize="300 150">
<PanelContainer StyleClasses="AngleRect">
<BoxContainer Orientation="Vertical" Margin="4">
<!-- Group Name input -->
<BoxContainer Orientation="Vertical" Margin="0 4">
<Label Text="{Loc nano-chat-group-name-label}"
StyleClasses="LabelHeading" />
<PanelContainer StyleClasses="ButtonSquare">
<LineEdit Name="GroupNameInput"
PlaceHolder="{Loc nano-chat-group-name-placeholder}" />
</PanelContainer>
</BoxContainer>
<!-- Action buttons -->
<BoxContainer Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0 8 0 0">
<Button Name="CancelButton"
Text="{Loc nano-chat-cancel}"
StyleClasses="OpenRight"
MinSize="80 0" />
<Button Name="CreateButton"
Text="{Loc nano-chat-create}"
StyleClasses="OpenLeft"
MinSize="80 0"
Disabled="True" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
</DefaultWindow>
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
namespace Content.Client._Funkystation.NanoChat;
[GenerateTypedNameReferences]
public sealed partial class CreateGroupChatPopup : DefaultWindow
{
public event Action<string>? OnGroupCreated;
public CreateGroupChatPopup(int maxFullNameLength)
{
RobustXamlLoader.Load(this);
ContentsContainer.Margin = new Thickness(3);
// Button handlers
CancelButton.OnPressed += _ => Close();
CreateButton.OnPressed += _ => CreateGroup();
// Input validation
GroupNameInput.OnTextEntered += _ => CreateGroup();
GroupNameInput.OnTextChanged += args =>
{
if (args.Text.Length > maxFullNameLength)
GroupNameInput.Text = args.Text[..maxFullNameLength];
ValidateInputs();
};
}
private void ValidateInputs()
{
var isValid = !string.IsNullOrWhiteSpace(GroupNameInput.Text);
CreateButton.Disabled = !isValid;
}
private void CreateGroup()
{
if (CreateButton.Disabled)
return;
var name = GroupNameInput.Text.Trim();
OnGroupCreated?.Invoke(name);
Close();
}
public void ClearInputs()
{
GroupNameInput.Text = string.Empty;
ValidateInputs();
}
}
@@ -0,0 +1,39 @@
<!--
SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
-->
<DefaultWindow xmlns="https://spacestation14.io"
Title="{Loc nano-chat-emoji-picker}"
MinSize="300 400">
<PanelContainer StyleClasses="AngleRect">
<BoxContainer Orientation="Vertical" Margin="4">
<!-- Search box -->
<LineEdit Name="SearchInput"
PlaceHolder="{Loc nano-chat-emoji-search}"
HorizontalExpand="True"
Margin="0 0 0 4" />
<!-- Emoji grid -->
<ScrollContainer VerticalExpand="True"
HorizontalExpand="True"
Margin="0 4"
HScroll="0"
VScroll="1">
<GridContainer Name="EmojiGrid"
Columns="6"
HorizontalExpand="True" />
</ScrollContainer>
<!-- Close button -->
<BoxContainer Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0 8 0 0">
<Button Name="EmojiCloseButton"
Text="{Loc nano-chat-close}"
MinSize="80 0" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
</DefaultWindow>
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
using System.Linq;
using Content.Shared._Funkystation.NanoChat;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Client.ResourceManagement;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Shared.Utility;
using System.Numerics;
namespace Content.Client._Funkystation.NanoChat;
[GenerateTypedNameReferences]
public sealed partial class EmojiPickerPopup : DefaultWindow
{
private List<(string name, SpriteSpecifier specifier)> _filteredEmojis;
private SpriteSystem? _spriteSystem;
public event Action<string>? OnEmojiSelected;
public EmojiPickerPopup()
{
RobustXamlLoader.Load(this);
_filteredEmojis = NanoChatEmojis.EmojiSpecifiers.Select(kv => (kv.Key, kv.Value)).ToList();
ContentsContainer.Margin = new Thickness(3);
EmojiCloseButton.OnPressed += _ => Close();
SearchInput.OnTextChanged += OnSearchChanged;
OnResized += UpdateGridColumns;
PopulateEmojiGrid();
UpdateGridColumns();
}
private void UpdateGridColumns()
{
const int buttonSize = 48;
const int scrollbarWidth = 12;
var scrollContainer = EmojiGrid.Parent as ScrollContainer;
if (scrollContainer == null)
return;
var availableWidth = scrollContainer.PixelSize.X - scrollbarWidth;
if (availableWidth <= 0)
return;
var columns = Math.Max(1, (int) (availableWidth / buttonSize));
if (EmojiGrid.Columns != columns)
{
EmojiGrid.Columns = columns;
}
}
private void OnSearchChanged(LineEdit.LineEditEventArgs args)
{
var search = args.Text.ToLower().Trim();
if (string.IsNullOrEmpty(search))
{
_filteredEmojis = NanoChatEmojis.EmojiSpecifiers.Select(kv => (kv.Key, kv.Value)).ToList();
}
else
{
_filteredEmojis = NanoChatEmojis.EmojiSpecifiers
.Where(kv => kv.Key.Contains(search, StringComparison.OrdinalIgnoreCase))
.Select(kv => (kv.Key, kv.Value))
.ToList();
}
PopulateEmojiGrid();
}
private void PopulateEmojiGrid()
{
EmojiGrid.RemoveAllChildren();
var resCache = IoCManager.Resolve<IResourceCache>();
_spriteSystem ??= IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
foreach (var (name, specifier) in _filteredEmojis)
{
var button = new Button
{
MinSize = new Vector2(48, 48),
MaxSize = new Vector2(48, 48),
ToolTip = $":{name}:",
};
Texture texture;
switch (specifier)
{
case SpriteSpecifier.Texture texSpecifier:
if (!resCache.TryGetResource<TextureResource>(texSpecifier.TexturePath, out var textureResource))
continue;
texture = textureResource.Texture;
break;
case SpriteSpecifier.Rsi rsiSpecifier:
var state = _spriteSystem.GetState(rsiSpecifier);
texture = state.Frame0;
break;
default:
continue;
}
var textureRect = new TextureRect
{
Texture = texture,
Stretch = TextureRect.StretchMode.KeepAspect,
MinSize = new Vector2(32, 32),
MaxSize = new Vector2(32, 32),
HorizontalAlignment = HAlignment.Center,
VerticalAlignment = VAlignment.Center,
};
button.AddChild(textureRect);
button.OnPressed += _ =>
{
OnEmojiSelected?.Invoke($":{name}:");
Close();
};
EmojiGrid.AddChild(button);
}
// Recalculate columns after populating
UpdateGridColumns();
}
public static SpriteSpecifier? GetEmojiSpecifier(string emojiName)
{
return NanoChatEmojis.EmojiSpecifiers.TryGetValue(emojiName, out var specifier) ? specifier : null;
}
public static Dictionary<string, SpriteSpecifier> GetEmojis() => new Dictionary<string, SpriteSpecifier>(NanoChatEmojis.EmojiSpecifiers);
}
@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.RichText;
using Content.Shared._Funkystation.NanoChat;
using Robust.Shared.Utility;
using Content.Client._DV.NanoChat.UI; // DeltaV - Fix emoji alignment
using Robust.Shared.Prototypes; // DeltaV - Emoji id for debugging
namespace Content.Client._Funkystation.NanoChat;
/// <summary>
/// Markup tag for rendering emoji images inline in RichTextLabel.
/// </summary>
public sealed class EmojiTag : IMarkupTagHandler
{
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
private SpriteSystem? _spriteSystem;
public EmojiTag()
{
IoCManager.InjectDependencies(this);
}
public string Name => "emoji";
// Begin DeltaV - Render invalid emoji as text
private static ProtoId<NanoChatEmojiPrototype>? GetEmoji(MarkupNode node, out string text, out SpriteSpecifier? emoji)
{
if (!node.Value.TryGetString(out var emojiName))
{
// If we don't have a value, then render nothing
emoji = null;
text = string.Empty;
return null;
}
else if (NanoChatEmojis.EmojiSpecifiers.TryGetValue(emojiName, out emoji))
{
// If we have a valid emoji, render the emoji, but no text
text = string.Empty;
return emojiName;
}
else
{
// If we have an invalid emoji, render only text
text = $":{emojiName}:";
return emojiName;
}
}
string IMarkupTagHandler.TextBefore(MarkupNode node)
{
GetEmoji(node, out var text, out _);
return text;
}
// End DeltaV - Render invalid emoji as text
public bool TryCreateControl(MarkupNode node, out Control control)
{
control = default!;
var emojiId = GetEmoji(node, out _, out var maybeSpecifier);
if (maybeSpecifier is not { } specifier)
{
return false;
}
_spriteSystem ??= _entitySystemManager.GetEntitySystem<SpriteSystem>();
Texture texture;
switch (specifier)
{
case SpriteSpecifier.Texture texSpecifier:
if (!_resourceCache.TryGetResource<TextureResource>(texSpecifier.TexturePath, out var textureResource))
{
return false;
}
texture = textureResource.Texture;
break;
case SpriteSpecifier.Rsi rsiSpecifier:
var state = _spriteSystem.GetState(rsiSpecifier);
texture = state.Frame0;
break;
default:
return false;
}
// Begin DeltaV - Fix emoji alignment, replace TextureRect with custom EmojiControl
control = new EmojiControl
{
Texture = texture,
SetWidth = 32f,
SetHeight = 32f,
Name = $"Emoji[{emojiId}]",
// Evilly hard-coded Margin and Offset, don't ask me where they come from please
Margin = new(5f, 0f, 2f, 0),
Offset = new(5f, -5f),
};
// End DeltaV
return true;
}
}
@@ -0,0 +1,32 @@
<!--
SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
-->
<DefaultWindow xmlns="https://spacestation14.io"
Title="{Loc nano-chat-group-members-title}"
MinSize="400 300">
<PanelContainer StyleClasses="AngleRect">
<BoxContainer Orientation="Vertical" Margin="4">
<!-- Member list -->
<ScrollContainer VerticalExpand="True"
HorizontalExpand="True"
Margin="0 4">
<BoxContainer Name="MembersList"
Orientation="Vertical"
VerticalExpand="True"
HorizontalExpand="True" />
</ScrollContainer>
<!-- Action buttons -->
<BoxContainer Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0 8 0 0">
<Button Name="MembersCloseButton"
Text="{Loc nano-chat-close}"
MinSize="80 0" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
</DefaultWindow>
@@ -0,0 +1,201 @@
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
using System.Linq;
using System.Numerics;
using Content.Client.Resources;
using Content.Shared._DV.CartridgeLoader.Cartridges;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.IoC;
using Robust.Client.ResourceManagement;
namespace Content.Client._Funkystation.NanoChat;
[GenerateTypedNameReferences]
public sealed partial class GroupMembersPopup : DefaultWindow
{
private List<NanoChatRecipient> _allContacts = new();
private HashSet<uint> _members = new();
private HashSet<uint> _admins = new();
private uint _ownNumber;
private uint? _creatorId;
public event Action<uint>? OnKick;
public event Action<uint>? OnAdmin;
public event Action<uint>? OnDeadmin;
public GroupMembersPopup()
{
RobustXamlLoader.Load(this);
ContentsContainer.Margin = new Thickness(3);
MembersCloseButton.OnPressed += _ => Close();
}
public void SetMembers(List<NanoChatRecipient> allContacts, HashSet<uint> members, uint ownNumber, uint? creatorId, HashSet<uint>? admins = null)
{
_allContacts = allContacts;
_members = members;
_ownNumber = ownNumber;
_creatorId = creatorId;
_admins = admins ?? new HashSet<uint>();
UpdateMembersList();
}
private void UpdateMembersList()
{
MembersList.RemoveAllChildren();
if (_members.Count == 0)
{
MembersList.AddChild(new Label { Text = Loc.GetString("nano-chat-no-members") });
return;
}
var isCreator = _creatorId == _ownNumber;
var isAdmin = _admins.Contains(_ownNumber);
var canManage = isCreator || isAdmin;
var memberContacts = _allContacts
.Where(c => _members.Contains(c.Number))
.OrderBy(c => c.Name)
.ToList();
for (var idx = 0; idx < memberContacts.Count; idx++)
{
var contact = memberContacts[idx];
var isEvenRow = idx % 2 == 0;
var isMemberAdmin = _admins.Contains(contact.Number);
var isMemberCreator = contact.Number == _creatorId;
var isSelf = contact.Number == _ownNumber;
var canKick = false;
if (isSelf) // can always leave yourself.
{
canKick = true;
}
else if (!isSelf && canManage && !isMemberCreator)
{
if (isCreator)
canKick = true;
else if (isAdmin && !isMemberAdmin)
canKick = true;
}
var canAdmin = canManage && !isSelf && !isMemberCreator && !isMemberAdmin;
var canDeadmin = canManage && !isSelf && !isMemberCreator && isMemberAdmin;
if (!isCreator && canDeadmin)
canDeadmin = false;
var contactControl = new MemberContainer(contact, isEvenRow, isMemberCreator, isMemberAdmin, isSelf, canKick, canAdmin, canDeadmin, OnKick, OnAdmin, OnDeadmin);
MembersList.AddChild(contactControl);
}
}
private sealed class MemberContainer : PanelContainer
{
public MemberContainer(NanoChatRecipient contact, bool isEvenRow, bool isCreator, bool isAdmin, bool isSelf, bool canKick, bool canMakeAdmin, bool canDeadmin, Action<uint>? onKick, Action<uint>? onAdmin, Action<uint>? onDeadmin)
{
HorizontalExpand = true;
StyleClasses.Add(isEvenRow ? "PanelBackgroundBaseDark" : "PanelBackgroundLight");
var resCache = IoCManager.Resolve<IResourceCache>();
// Show name with job title if available
var displayName = contact.Name;
if (!string.IsNullOrWhiteSpace(contact.JobTitle))
displayName = $"{contact.Name} ({contact.JobTitle})";
var nameLabel = new Label
{
Text = displayName + (isCreator ? $" {Loc.GetString("nano-chat-owner-suffix")}" : isAdmin ? $" {Loc.GetString("nano-chat-admin-suffix")}" : ""),
HorizontalAlignment = HAlignment.Left,
HorizontalExpand = true
};
var numberLabel = new Label
{
Text = $"#{contact.Number:D4}",
HorizontalAlignment = HAlignment.Right,
Margin = new Thickness(0, 0, (canKick || canMakeAdmin || canDeadmin) ? 108 : 4, 0),
};
AddChild(nameLabel);
AddChild(numberLabel);
// Admin/Deadmin button
if (canMakeAdmin)
{
var adminButton = new Button
{
HorizontalAlignment = HAlignment.Right,
MinSize = new Vector2(32, 32),
MaxSize = new Vector2(32, 32),
ToolTip = Loc.GetString("nano-chat-admin"),
Margin = new Thickness(0, 0, canKick ? 72 : 36, 0),
};
adminButton.AddStyleClass("OpenBoth");
adminButton.AddChild(new TextureRect
{
Texture = resCache.GetTexture("/Textures/_Funkystation/Interface/NanoChat/admin.png"),
Stretch = TextureRect.StretchMode.KeepAspectCentered,
MinSize = new Vector2(28, 28),
MaxSize = new Vector2(28, 28),
});
adminButton.OnPressed += _ => onAdmin?.Invoke(contact.Number);
AddChild(adminButton);
}
else if (canDeadmin)
{
var deadminButton = new Button
{
HorizontalAlignment = HAlignment.Right,
MinSize = new Vector2(32, 32),
MaxSize = new Vector2(32, 32),
ToolTip = Loc.GetString("nano-chat-deadmin"),
Margin = new Thickness(0, 0, canKick ? 72 : 36, 0),
};
deadminButton.AddStyleClass("OpenBoth");
deadminButton.AddChild(new TextureRect
{
Texture = resCache.GetTexture("/Textures/_Funkystation/Interface/NanoChat/deadmin.png"),
Stretch = TextureRect.StretchMode.KeepAspectCentered,
MinSize = new Vector2(28, 28),
MaxSize = new Vector2(28, 28),
});
deadminButton.OnPressed += _ => onDeadmin?.Invoke(contact.Number);
AddChild(deadminButton);
}
// Kick/Leave button
if (canKick)
{
var kickButton = new Button
{
Text = isSelf ? Loc.GetString("nano-chat-leave") : "X",
HorizontalAlignment = HAlignment.Right,
MinSize = new Vector2(isSelf ? 64 : 32, 32),
MaxSize = new Vector2(isSelf ? 64 : 32, 32),
ToolTip = isSelf ? Loc.GetString("nano-chat-leave") : Loc.GetString("nano-chat-kick"),
Margin = new Thickness(0, 0, 36, 0),
};
kickButton.AddStyleClass("OpenBoth");
kickButton.OnPressed += _ => onKick?.Invoke(contact.Number);
AddChild(kickButton);
}
}
}
}
@@ -0,0 +1,43 @@
<!--
SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
-->
<DefaultWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
Title="{Loc nano-chat-invite-to-group-title}"
MinSize="400 300">
<PanelContainer StyleClasses="AngleRect">
<BoxContainer Orientation="Vertical" Margin="4">
<!-- Search input -->
<BoxContainer Orientation="Vertical" Margin="0 4">
<Label Text="{Loc nano-chat-search-label}"
StyleClasses="LabelHeading" />
<PanelContainer StyleClasses="ButtonSquare">
<LineEdit Name="SearchInput"
PlaceHolder="{Loc nano-chat-search-placeholder}" />
</PanelContainer>
</BoxContainer>
<!-- Contact list -->
<ScrollContainer VerticalExpand="True"
HorizontalExpand="True"
Margin="0 4">
<BoxContainer Name="ContactsList"
Orientation="Vertical"
VerticalExpand="True"
HorizontalExpand="True" />
</ScrollContainer>
<!-- Action buttons -->
<BoxContainer Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0 8 0 0">
<Button Name="InviteCloseButton"
Text="{Loc nano-chat-close}"
MinSize="80 0" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
</DefaultWindow>
@@ -0,0 +1,191 @@
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
using System.Linq;
using System.Numerics;
using Content.Shared._DV.CartridgeLoader.Cartridges;
using Content.Shared.Roles;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Client._Funkystation.NanoChat;
[GenerateTypedNameReferences]
public sealed partial class InviteToGroupPopup : DefaultWindow
{
private List<NanoChatRecipient> _allContacts = new();
private HashSet<uint> _existingMembers = new();
private uint _ownNumber;
public event Action<uint>? OnInvite;
public InviteToGroupPopup()
{
RobustXamlLoader.Load(this);
ContentsContainer.Margin = new Thickness(3);
InviteCloseButton.OnPressed += _ => Close();
SearchInput.OnTextChanged += _ => UpdateContactList();
}
public void SetContacts(List<NanoChatRecipient> contacts, HashSet<uint> existingMembers, uint ownNumber)
{
_allContacts = contacts;
_existingMembers = existingMembers;
_ownNumber = ownNumber;
UpdateContactList();
}
private void UpdateContactList()
{
ContactsList.RemoveAllChildren();
if (_allContacts.Count == 0)
{
ContactsList.AddChild(new Label { Text = Loc.GetString("nano-chat-look-up-no-server") });
return;
}
var searchText = SearchInput.Text.ToLower();
var filteredContacts = _allContacts
.Where(c => c.Number != _ownNumber &&
!_existingMembers.Contains(c.Number) &&
(string.IsNullOrWhiteSpace(searchText) ||
c.Name.ToLower().Contains(searchText) ||
c.Number.ToString().Contains(searchText) ||
(!string.IsNullOrWhiteSpace(c.JobTitle) && c.JobTitle.ToLower().Contains(searchText))))
.ToList();
if (filteredContacts.Count == 0)
{
ContactsList.AddChild(new Label { Text = Loc.GetString("nano-chat-no-results") });
return;
}
// Group contacts by department like crew manifest
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
var departmentGroups = new Dictionary<DepartmentPrototype, List<NanoChatRecipient>>();
var noDepartmentContacts = new List<NanoChatRecipient>();
foreach (var contact in filteredContacts)
{
if (!string.IsNullOrWhiteSpace(contact.Department) &&
prototypeManager.TryIndex<DepartmentPrototype>(contact.Department, out var dept))
{
departmentGroups.GetOrNew(dept).Add(contact);
}
else
{
noDepartmentContacts.Add(contact);
}
}
// Sort departments by weight
var sortedDepartments = departmentGroups.Keys.ToList();
sortedDepartments.Sort(DepartmentUIComparer.Instance);
var rowIndex = 0;
foreach (var department in sortedDepartments)
{
var departmentContacts = departmentGroups[department];
departmentContacts.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name));
// Add department header
ContactsList.AddChild(new Label
{
Text = Loc.GetString(department.Name),
StyleClasses = { "LabelBig" },
Margin = new Thickness(0, rowIndex == 0 ? 0 : 8, 0, 2)
});
foreach (var contact in departmentContacts)
{
var isEvenRow = rowIndex % 2 == 0;
var contactControl = new ContactContainer(contact, isEvenRow, OnInvite);
ContactsList.AddChild(contactControl);
rowIndex++;
}
}
// Add contacts without departments at the end
if (noDepartmentContacts.Count > 0)
{
noDepartmentContacts.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name));
if (sortedDepartments.Count > 0)
{
ContactsList.AddChild(new Label
{
Text = Loc.GetString("nano-chat-other-department"),
StyleClasses = { "LabelBig" },
Margin = new Thickness(0, 8, 0, 2)
});
}
foreach (var contact in noDepartmentContacts)
{
var isEvenRow = rowIndex % 2 == 0;
var contactControl = new ContactContainer(contact, isEvenRow, OnInvite);
ContactsList.AddChild(contactControl);
rowIndex++;
}
}
}
public void ClearSearch()
{
SearchInput.Text = string.Empty;
}
private sealed class ContactContainer : PanelContainer
{
public ContactContainer(NanoChatRecipient contact, bool isEvenRow, Action<uint>? onInvite)
{
HorizontalExpand = true;
StyleClasses.Add(isEvenRow ? "PanelBackgroundBaseDark" : "PanelBackgroundLight");
// Show name with job title if available
var displayName = contact.Name;
if (!string.IsNullOrWhiteSpace(contact.JobTitle))
displayName = $"{contact.Name} ({contact.JobTitle})";
var nameLabel = new Label
{
Text = displayName,
HorizontalAlignment = HAlignment.Left,
HorizontalExpand = true
};
var numberLabel = new Label
{
Text = $"#{contact.Number:D4}",
HorizontalAlignment = HAlignment.Right,
Margin = new Thickness(0, 0, 36, 0),
};
var inviteButton = new Button
{
Text = "+",
HorizontalAlignment = HAlignment.Right,
MinSize = new Vector2(32, 32),
MaxSize = new Vector2(32, 32),
ToolTip = Loc.GetString("nano-chat-invite"),
};
inviteButton.AddStyleClass("OpenBoth");
inviteButton.OnPressed += _ => onInvite?.Invoke(contact.Number);
AddChild(nameLabel);
AddChild(numberLabel);
AddChild(inviteButton);
}
}
}
@@ -46,11 +46,17 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
SubscribeLocalEvent<NanoChatCartridgeComponent, CartridgeUiReadyEvent>(OnUiReady);
SubscribeLocalEvent<NanoChatCartridgeComponent, CartridgeMessageEvent>(OnMessage);
SubscribeLocalEvent<NanoChatCartridgeComponent, MapInitEvent>(OnMapInit);
Subs.CVar(_cfg, CCVars.MaxNameLength, x => _maxNameLength = x, true);
Subs.CVar(_cfg, CCVars.MaxIdJobLength, x => _maxIdJobLength = x, true);
}
private void OnMapInit(Entity<NanoChatCartridgeComponent> ent, ref MapInitEvent args)
{
UpdateUIForAllCards();
}
private void UpdateClosed(Entity<NanoChatCartridgeComponent> ent)
{
if (!TryComp<CartridgeComponent>(ent, out var cartridge) ||
@@ -124,7 +130,7 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
HandleCloseChat(card);
break;
case NanoChatUiMessageType.ToggleMute:
HandleToggleMute(card);
HandleToggleMute(card, msg);
break;
case NanoChatUiMessageType.ToggleMuteChat:
HandleToggleMuteChat(card, msg);
@@ -136,7 +142,22 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
HandleSendMessage(ent, card, msg);
break;
case NanoChatUiMessageType.ToggleListNumber:
HandleToggleListNumber(card);
HandleToggleListNumber(card, msg);
break;
case NanoChatUiMessageType.CreateGroupChat:
HandleCreateGroupChat(card, msg);
break;
case NanoChatUiMessageType.InviteToGroup:
HandleInviteToGroup(card, msg);
break;
case NanoChatUiMessageType.KickFromGroup:
HandleKickFromGroup(card, msg);
break;
case NanoChatUiMessageType.AdminUser:
HandleAdminUser(card, msg);
break;
case NanoChatUiMessageType.DeadminUser:
HandleDeadminUser(card, msg);
break;
}
@@ -190,16 +211,24 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
}
// Add new recipient
var recipient = new NanoChatRecipient(msg.RecipientNumber.Value,
var recipient = new NanoChatRecipient(
msg.RecipientNumber.Value,
name,
jobTitle);
jobTitle,
null,
false,
false,
null,
null,
null
);
// 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} ({name})");
$"{ToPrettyString(msg.Actor)} created new NanoChat conversation with #{msg.RecipientNumber:D4} ({name})");
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
RaiseLocalEvent(ref recipientEv);
@@ -277,15 +306,65 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
if (msg.RecipientNumber == null || card.Comp.Number == null)
return;
var chatNumber = msg.RecipientNumber.Value;
var recipient = _nanoChat.GetRecipient((card, card.Comp), chatNumber);
if (recipient is { IsGroup: true } groupChat)
{
var members = groupChat.Members ?? new HashSet<uint>();
if (members.Remove(card.Comp.Number.Value))
{
var admins = groupChat.Admins ?? new HashSet<uint>();
admins.Remove(card.Comp.Number.Value);
// If the creator is leaving, transfer ownership
uint? newCreatorId = groupChat.CreatorId;
var isCreatorLeaving = card.Comp.Number.Value == groupChat.CreatorId;
if (isCreatorLeaving)
{
if (admins.Count > 0)
{
newCreatorId = admins.First();
}
else if (members.Count > 0)
{
newCreatorId = members.First();
}
else
{
newCreatorId = null;
}
}
// Update the group for all remaining members
var updatedRecipient = newCreatorId != null
? groupChat with { Members = members, Admins = admins, CreatorId = newCreatorId }
: groupChat with { Members = members, Admins = admins };
foreach (var memberNumber in members)
{
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
while (cardQuery.MoveNext(out var memberCardUid, out var memberCard))
{
if (memberCard.Number == memberNumber)
{
_nanoChat.SetRecipient((memberCardUid, memberCard), chatNumber, updatedRecipient);
UpdateUIForCard(memberCardUid);
}
}
}
}
}
// Delete chat but keep the messages
var deleted = _nanoChat.TryDeleteChat((card, card.Comp), msg.RecipientNumber.Value, true);
var deleted = _nanoChat.TryDeleteChat((card, card.Comp), chatNumber, true);
if (!deleted)
return;
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(msg.Actor):user} deleted NanoChat conversation with #{msg.RecipientNumber:D4}");
$"{ToPrettyString(msg.Actor)} deleted NanoChat conversation with #{chatNumber:D4}");
UpdateUIForCard(card);
}
@@ -293,9 +372,15 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
/// <summary>
/// Handles toggling notification mute state.
/// </summary>
private void HandleToggleMute(Entity<NanoChatCardComponent> card)
private void HandleToggleMute(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
{
_nanoChat.SetNotificationsMuted((card, card.Comp), !_nanoChat.GetNotificationsMuted((card, card.Comp)));
var muted = _nanoChat.GetNotificationsMuted((card, card.Comp));
_nanoChat.SetNotificationsMuted((card, card.Comp), !muted);
_adminLogger.Add(LogType.Chat,
LogImpact.Low,
$"{ToPrettyString(msg.Actor)} {(muted ? "un" : "")}muted the NanoChat on {ToPrettyString(card)}");
UpdateUIForCard(card);
}
@@ -303,13 +388,25 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
{
if (msg.RecipientNumber is not uint chat)
return;
_nanoChat.ToggleChatMuted((card, card.Comp), chat);
_adminLogger.Add(LogType.Chat,
LogImpact.Low,
$"{ToPrettyString(msg.Actor)} toggled NanoChat notifcations for chat #{chat:D4} on {ToPrettyString(card)}");
UpdateUIForCard(card);
}
private void HandleToggleListNumber(Entity<NanoChatCardComponent> card)
private void HandleToggleListNumber(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
{
_nanoChat.SetListNumber((card, card.Comp), !_nanoChat.GetListNumber((card, card.Comp)));
var numberListed = _nanoChat.GetListNumber((card, card.Comp));
_nanoChat.SetListNumber((card, card.Comp), !numberListed);
_adminLogger.Add(LogType.Chat,
LogImpact.Low,
$"{ToPrettyString(msg.Actor)} toggled the NanoChat listing of {ToPrettyString(card)} {(numberListed ? "off" : "on")}");
UpdateUIForAllCards();
}
@@ -323,7 +420,11 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
return;
if (!EnsureRecipientExists(card, msg.RecipientNumber.Value))
var recipient = _nanoChat.GetRecipient((card, card.Comp), msg.RecipientNumber.Value);
var isGroupChat = recipient?.IsGroup ?? false;
// Only ensure recipient exists for non-group chats
if (!isGroupChat && !EnsureRecipientExists(card, msg.RecipientNumber.Value))
return;
var content = msg.Content;
@@ -341,8 +442,19 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
(uint)card.Comp.Number
);
// Attempt delivery
var (deliveryFailed, recipients) = AttemptMessageDelivery(cartridge, msg.RecipientNumber.Value);
List<Entity<NanoChatCardComponent>> recipients;
bool deliveryFailed;
if (isGroupChat && recipient != null)
{
// For group chats, deliver to all members
(deliveryFailed, recipients) = AttemptGroupMessageDelivery(cartridge, recipient.Value, card.Comp.Number.Value);
}
else
{
// For regular chats, deliver to single recipient
(deliveryFailed, recipients) = AttemptMessageDelivery(cartridge, msg.RecipientNumber.Value);
}
// Update delivery status
message = message with { DeliveryFailed = deliveryFailed };
@@ -357,7 +469,7 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
_adminLogger.Add(LogType.Chat,
LogImpact.Low,
$"{ToPrettyString(card):user} sent NanoChat message to {recipientsText}: {content}{(deliveryFailed ? " [DELIVERY FAILED]" : "")}");
$"{ToPrettyString(msg.Actor)} sent NanoChat message to {recipientsText}: {content}{(deliveryFailed ? " [DELIVERY FAILED]" : "")}");
var msgEv = new NanoChatMessageReceivedEvent(card);
RaiseLocalEvent(ref msgEv);
@@ -365,9 +477,9 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
if (deliveryFailed)
return;
foreach (var recipient in recipients)
foreach (var recipientCard in recipients)
{
DeliverMessageToRecipient(card, recipient, message);
DeliverMessageToRecipient(card, recipientCard, message, msg.RecipientNumber.Value, recipient);
}
}
@@ -480,27 +592,88 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
/// <param name="sender">The sender's card entity</param>
/// <param name="recipient">The recipient's card entity</param>
/// <param name="message">The <see cref="NanoChatMessage" /> to deliver</param>
/// <param name="chatNumber">The chat number (for group chats, this is the group number)</param>
/// <param name="groupRecipient">Optional group recipient info if this is a group chat</param>
private void DeliverMessageToRecipient(Entity<NanoChatCardComponent> sender,
Entity<NanoChatCardComponent> recipient,
NanoChatMessage message)
NanoChatMessage message,
uint chatNumber,
NanoChatRecipient? groupRecipient = null)
{
if (sender.Comp.Number is not uint senderNumber)
return;
// Always try to get and add sender info to recipient's contacts
if (!EnsureRecipientExists(recipient, senderNumber))
// For group chats, use the group number. For regular chats, use the sender's number
var recipientNumber = (groupRecipient != null && groupRecipient.Value.IsGroup) ? chatNumber : senderNumber;
if (groupRecipient != null && groupRecipient.Value.IsGroup)
{
var existingRecipient = _nanoChat.GetRecipient((recipient, recipient.Comp), recipientNumber);
if (existingRecipient == null || !existingRecipient.Value.IsGroup)
{
_nanoChat.SetRecipient((recipient, recipient.Comp), recipientNumber, groupRecipient.Value);
}
else if (groupRecipient.Value.Members != null)
{
var existingMembers = existingRecipient.Value.Members ?? new HashSet<uint>();
if (!existingMembers.SetEquals(groupRecipient.Value.Members))
{
_nanoChat.SetRecipient((recipient, recipient.Comp), recipientNumber, groupRecipient.Value);
}
else
{
var existingAdmins = existingRecipient.Value.Admins ?? new HashSet<uint>();
var groupAdmins = groupRecipient.Value.Admins ?? new HashSet<uint>();
if (!existingAdmins.SetEquals(groupAdmins))
{
_nanoChat.SetRecipient((recipient, recipient.Comp), recipientNumber, groupRecipient.Value);
}
}
}
}
else if (!EnsureRecipientExists(recipient, recipientNumber))
{
return;
}
_nanoChat.AddMessage((recipient, recipient.Comp), senderNumber, message with { DeliveryFailed = false });
_nanoChat.AddMessage((recipient, recipient.Comp), recipientNumber, message with { DeliveryFailed = false });
if (recipient.Comp.IsClosed || _nanoChat.GetCurrentChat((recipient, recipient.Comp)) != senderNumber)
HandleUnreadNotification(recipient, message, senderNumber);
if (recipient.Comp.IsClosed || _nanoChat.GetCurrentChat((recipient, recipient.Comp)) != recipientNumber)
HandleUnreadNotification(recipient, message, recipientNumber);
var msgEv = new NanoChatMessageReceivedEvent(recipient);
RaiseLocalEvent(ref msgEv);
UpdateUIForCard(recipient);
}
/// <summary>
/// Attempts to deliver a message to all members of a group chat.
/// </summary>
private (bool failed, List<Entity<NanoChatCardComponent>> recipients) AttemptGroupMessageDelivery(
Entity<NanoChatCartridgeComponent> sender,
NanoChatRecipient groupRecipient,
uint senderNumber)
{
if (groupRecipient.Members == null)
return (true, new List<Entity<NanoChatCardComponent>>());
var deliverableRecipients = new List<Entity<NanoChatCardComponent>>();
foreach (var memberNumber in groupRecipient.Members)
{
if (memberNumber == senderNumber)
continue;
var (failed, memberCards) = AttemptMessageDelivery(sender, memberNumber);
if (!failed)
deliverableRecipients.AddRange(memberCards);
}
// Delivery failed if no recipients could receive the message
return (deliverableRecipients.Count == 0, deliverableRecipients);
}
/// <summary>
/// Handles unread message notifications and updates unread status.
/// </summary>
@@ -510,21 +683,21 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
{
// 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 senderRecipient)
var senderName = recipients.TryGetValue(senderNumber, out var senderRecipient)
? senderRecipient.Name
: $"#{message.SenderId:D4}";
: $"#{senderNumber:D4}";
var hasSelectedCurrentChat = _nanoChat.GetCurrentChat((recipient, recipient.Comp)) == senderNumber;
// Update unread status
if (!hasSelectedCurrentChat)
_nanoChat.SetRecipient((recipient, recipient.Comp),
message.SenderId,
senderNumber,
senderRecipient with { HasUnread = true });
// Temporary local to avoid trouble with read-only access; Contains doesn't modify the collection
HashSet<uint> mutedChats = recipient.Comp.MutedChats;
if (recipient.Comp.NotificationsMuted ||
mutedChats.Contains(message.SenderId) ||
mutedChats.Contains(senderNumber) ||
recipient.Comp.PdaUid is not { } pdaUid ||
!TryComp<CartridgeLoaderComponent>(pdaUid, out var loader) ||
// Don't notify if the recipient has the NanoChat program open with this chat selected.
@@ -593,14 +766,27 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
// Try to get job title from ID card if possible
string? jobTitle = null;
string? department = null;
var name = "Unknown";
if (TryComp<IdCardComponent>(uid, out var idCard))
{
jobTitle = idCard.LocalizedJobTitle;
name = idCard.FullName ?? name;
if (idCard.JobDepartments is { } depts && depts.Count > 0)
department = depts[0].ToString();
}
return new NanoChatRecipient(number, name, jobTitle);
return new NanoChatRecipient(
number,
name,
jobTitle,
department,
false,
false,
null,
null,
null
);
}
return null;
@@ -624,12 +810,34 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
var query = AllEntityQuery<NanoChatCardComponent, IdCardComponent>();
while (query.MoveNext(out var entityId, out var nanoChatCard, out var idCardComponent))
{
if (nanoChatCard.ListNumber && nanoChatCard.Number is uint nanoChatNumber && idCardComponent.FullName is string fullName && _station.GetOwningStation(entityId) == station)
{
contacts.Add(new NanoChatRecipient(nanoChatNumber, fullName));
}
if (!nanoChatCard.ListNumber)
continue;
if (nanoChatCard.PdaUid is null)
continue;
if (nanoChatCard.Number is not { } nanoChatNumber)
continue;
if (idCardComponent.FullName is not { } fullName)
continue;
if (_station.GetOwningStation(entityId) != station)
continue;
string? department = null;
if (idCardComponent.JobDepartments is { } depts && depts.Count > 0)
department = depts[0].ToString();
contacts.Add(new NanoChatRecipient(
nanoChatNumber,
fullName,
idCardComponent.LocalizedJobTitle,
department,
false,
false,
null,
null,
null
));
}
contacts.Sort((contactA, contactB) => string.CompareOrdinal(contactA.Name, contactB.Name));
contacts.Sort();
}
else
{
@@ -668,4 +876,314 @@ public sealed class NanoChatCartridgeSystem : EntitySystem
listNumber);
_cartridge.UpdateCartridgeUiState(loader, state);
}
/// <summary>
/// Handles creation of a new group chat.
/// </summary>
private void HandleCreateGroupChat(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
{
if (msg.Content == null || card.Comp.Number == null)
return;
var name = msg.Content;
if (!string.IsNullOrWhiteSpace(name))
{
name = name.Trim();
if (name.Length > _maxNameLength)
name = name[.._maxNameLength];
}
// Generate a unique group number (surely unique I actually have no idea how to generate good unique numbers.)
var groupNumber = (uint)(HashCode.Combine(card.Comp.Number.Value, _timing.CurTime.Ticks) & 0x7FFFFFFF);
// This fucking sucks (fire emoji)
while (_nanoChat.GetRecipient((card, card.Comp), groupNumber) != null)
{
groupNumber++;
}
// Create group chat recipient
var recipient = new NanoChatRecipient(
groupNumber,
name,
null,
null,
false,
true,
[card.Comp.Number.Value],
card.Comp.Number.Value,
null
);
_nanoChat.SetRecipient((card, card.Comp), groupNumber, recipient);
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(msg.Actor)} created group chat '{name}' (#{groupNumber:D4})");
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
RaiseLocalEvent(ref recipientEv);
UpdateUIForCard(card);
}
/// <summary>
/// Handles inviting a user to a group chat.
/// </summary>
private void HandleInviteToGroup(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
{
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
return;
var groupNumber = msg.RecipientNumber.Value;
if (!uint.TryParse(msg.Content, out var inviteeNumber))
return;
var recipient = _nanoChat.GetRecipient((card, card.Comp), groupNumber);
if (recipient == null || !recipient.Value.IsGroup)
return;
// Only the creator or admins can invite
var isCreator = recipient.Value.CreatorId == card.Comp.Number.Value;
var admins = recipient.Value.Admins ?? new HashSet<uint>();
var isAdmin = admins.Contains(card.Comp.Number.Value);
if (!isCreator && !isAdmin)
return;
var members = recipient.Value.Members ?? new HashSet<uint>();
if (members.Contains(inviteeNumber))
return;
// Add member to group
members.Add(inviteeNumber);
var updatedRecipient = recipient.Value with { Members = members };
_nanoChat.SetRecipient((card, card.Comp), groupNumber, updatedRecipient);
// Update member lists for all members
var memberCards = new List<Entity<NanoChatCardComponent>>();
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
while (cardQuery.MoveNext(out var cardUid, out var memberCard))
{
if (memberCard.Number != null && members.Contains(memberCard.Number.Value))
{
memberCards.Add((cardUid, memberCard));
}
}
foreach (var memberCard in memberCards)
{
_nanoChat.SetRecipient((memberCard, memberCard.Comp), groupNumber, updatedRecipient);
UpdateUIForCard(memberCard);
}
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(msg.Actor)} invited #{inviteeNumber:D4} to group chat #{groupNumber:D4}");
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
RaiseLocalEvent(ref recipientEv);
UpdateUIForCard(card);
}
/// <summary>
/// Handles kicking a user from a group chat.
/// </summary>
private void HandleKickFromGroup(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
{
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
return;
var groupNumber = msg.RecipientNumber.Value;
if (!uint.TryParse(msg.Content, out var kickeeNumber))
return;
var recipient = _nanoChat.GetRecipient((card, card.Comp), groupNumber);
if (recipient == null || !recipient.Value.IsGroup)
return;
var isCreator = recipient.Value.CreatorId == card.Comp.Number.Value;
var admins = recipient.Value.Admins ?? new HashSet<uint>();
var isAdmin = admins.Contains(card.Comp.Number.Value);
var isSelfKick = kickeeNumber == card.Comp.Number.Value;
if (!isSelfKick && !isCreator && !isAdmin)
return;
// Allow creator to leave the group but not be kicked by other members.
var isCreatorLeaving = kickeeNumber == recipient.Value.CreatorId && isCreator;
if (kickeeNumber == recipient.Value.CreatorId && !isCreatorLeaving)
return;
// Find all cards belonging to the kickee
var kickeeCards = new List<Entity<NanoChatCardComponent>>();
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
while (cardQuery.MoveNext(out var cardUid, out var kickeeCard))
{
if (kickeeCard.Number == kickeeNumber)
{
kickeeCards.Add((cardUid, kickeeCard));
}
}
foreach (var kickeeCard in kickeeCards)
{
var deleteMsg = new NanoChatUiMessageEvent(NanoChatUiMessageType.DeleteChat, groupNumber, null, null)
{
Actor = msg.Actor
};
HandleDeleteChat(kickeeCard, deleteMsg);
}
if (isCreatorLeaving)
{
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(msg.Actor)} left group chat #{groupNumber:D4}");
}
else
{
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(msg.Actor)} kicked #{kickeeNumber:D4} from group chat #{groupNumber:D4}");
}
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
RaiseLocalEvent(ref recipientEv);
UpdateUIForCard(card);
}
/// <summary>
/// Handles promoting a user to admin in a group chat.
/// </summary>
private void HandleAdminUser(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
{
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
return;
var groupNumber = msg.RecipientNumber.Value;
if (!uint.TryParse(msg.Content, out var targetNumber))
return;
var recipient = _nanoChat.GetRecipient((card, card.Comp), groupNumber);
if (recipient == null || !recipient.Value.IsGroup)
return;
// Only the creator or admins can make admins
var isCreator = recipient.Value.CreatorId == card.Comp.Number.Value;
var admins = recipient.Value.Admins ?? new HashSet<uint>();
var isAdmin = admins.Contains(card.Comp.Number.Value);
if (!isCreator && !isAdmin)
return;
var members = recipient.Value.Members ?? new HashSet<uint>();
if (!members.Contains(targetNumber))
return; // how tf
if (targetNumber == recipient.Value.CreatorId)
return;
if (!isCreator && admins.Contains(targetNumber))
return;
if (!admins.Add(targetNumber))
return;
var updatedRecipient = recipient.Value with { Admins = admins };
_nanoChat.SetRecipient((card, card.Comp), groupNumber, updatedRecipient);
// Sync to all members
foreach (var memberNumber in members)
{
var memberCards = new List<Entity<NanoChatCardComponent>>();
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
while (cardQuery.MoveNext(out var cardUid, out var memberCard))
{
if (memberCard.Number == memberNumber)
{
memberCards.Add((cardUid, memberCard));
}
}
foreach (var memberCard in memberCards)
{
_nanoChat.SetRecipient((memberCard, memberCard.Comp), groupNumber, updatedRecipient);
UpdateUIForCard(memberCard);
}
}
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(msg.Actor)} promoted #{targetNumber:D4} to admin in group chat #{groupNumber:D4}");
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
RaiseLocalEvent(ref recipientEv);
UpdateUIForCard(card);
}
/// <summary>
/// Handles demoting a user from admin in a group chat.
/// </summary>
private void HandleDeadminUser(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
{
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
return;
var groupNumber = msg.RecipientNumber.Value;
if (!uint.TryParse(msg.Content, out var targetNumber))
return;
var recipient = _nanoChat.GetRecipient((card, card.Comp), groupNumber);
if (recipient == null || !recipient.Value.IsGroup)
return;
// Only the creator or admins can remove admins
var isCreator = recipient.Value.CreatorId == card.Comp.Number.Value;
var admins = recipient.Value.Admins ?? new HashSet<uint>();
var isAdmin = admins.Contains(card.Comp.Number.Value);
if (!isCreator && !isAdmin)
return;
var members = recipient.Value.Members ?? new HashSet<uint>();
if (!members.Contains(targetNumber))
return; // how
if (!isCreator && (targetNumber == recipient.Value.CreatorId || admins.Contains(targetNumber)))
return;
if (!admins.Remove(targetNumber))
return;
var updatedRecipient = recipient.Value with { Admins = admins };
_nanoChat.SetRecipient((card, card.Comp), groupNumber, updatedRecipient);
// Sync to all members
foreach (var memberNumber in members)
{
var memberCards = new List<Entity<NanoChatCardComponent>>();
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
while (cardQuery.MoveNext(out var cardUid, out var memberCard))
{
if (memberCard.Number == memberNumber)
{
memberCards.Add((cardUid, memberCard));
}
}
foreach (var memberCard in memberCards)
{
_nanoChat.SetRecipient((memberCard, memberCard.Comp), groupNumber, updatedRecipient);
UpdateUIForCard(memberCard);
}
}
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(msg.Actor)} removed admin from #{targetNumber:D4} in group chat #{groupNumber:D4}");
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
RaiseLocalEvent(ref recipientEv);
UpdateUIForCard(card);
}
}
@@ -1,4 +1,5 @@
using Content.Shared.CartridgeLoader;
using Content.Shared.Roles;
using Robust.Shared.Serialization;
namespace Content.Shared._DV.CartridgeLoader.Cartridges;
@@ -57,45 +58,107 @@ public enum NanoChatUiMessageType : byte
ToggleMute,
ToggleMuteChat,
ToggleListNumber,
CreateGroupChat,
InviteToGroup,
KickFromGroup,
ViewGroupMembers,
AdminUser,
DeadminUser,
}
// putting this here because i can
/// <summary>
/// Represents an actual NanoChat Chat
/// Either a Group, if <paramref name="isGroup"/> is <see langword="true" />, else a normal user.
/// </summary>
/// <param name="number">The recipient's NanoChat number</param>
/// <param name="name">The recipient's display name</param>
/// <param name="jobTitle">Optional job title for the recipient</param>
/// <param name="department">Optional department ID for the recipient</param>
/// <param name="hasUnread">Whether there are unread messages from this recipient</param>
/// <param name="isGroup">Whether this is a group chat</param>
/// <param name="members">For group chats: list of member NanoChat numbers</param>
/// <param name="creatorId">For group chats: the creator's NanoChat number</param>
/// <param name="admins">For group chats: set of admin NanoChat numbers</param>
[Serializable, NetSerializable, DataRecord]
public struct NanoChatRecipient
public struct NanoChatRecipient(
uint number,
string name,
string? jobTitle,
string? department,
bool hasUnread,
bool isGroup,
HashSet<uint>? members,
uint? creatorId,
HashSet<uint>? admins
) : IComparable<NanoChatRecipient>
{
/// <summary>
/// The recipient's unique NanoChat number.
/// </summary>
public uint Number;
public uint Number = number;
/// <summary>
/// The recipient's display name, typically from their ID card.
/// </summary>
public string Name;
public string Name = name;
/// <summary>
/// The recipient's job title, if available.
/// </summary>
public string? JobTitle;
public string? JobTitle = jobTitle;
/// <summary>
/// The recipient's department ID, if available.
/// </summary>
public string? Department = department;
/// <summary>
/// Whether this recipient has unread messages.
/// </summary>
public bool HasUnread;
public bool HasUnread = hasUnread;
/// <summary>
/// Creates a new NanoChat recipient.
/// Whether this is a group chat.
/// </summary>
/// <param name="number">The recipient's NanoChat number</param>
/// <param name="name">The recipient's display name</param>
/// <param name="jobTitle">Optional job title for the recipient</param>
/// <param name="hasUnread">Whether there are unread messages from this recipient</param>
public NanoChatRecipient(uint number, string name, string? jobTitle = null, bool hasUnread = false)
public bool IsGroup = isGroup;
/// <summary>
/// For group chats: list of member NanoChat numbers.
/// </summary>
public HashSet<uint>? Members = members;
/// <summary>
/// For group chats: the NanoChat number of the creator.
/// </summary>
public uint? CreatorId = creatorId;
/// <summary>
/// For group chats: set of admin NanoChat numbers who can invite/kick.
/// </summary>
public HashSet<uint>? Admins = admins;
readonly int IComparable<NanoChatRecipient>.CompareTo(NanoChatRecipient other)
{
Number = number;
Name = name;
JobTitle = jobTitle;
HasUnread = hasUnread;
// Groups should go before normal users
if (IsGroup && !other.IsGroup)
return -1;
else if (!IsGroup && other.IsGroup)
return 1;
// FIXME: Order by Department?
// Order by name
var nameCompare = string.CompareOrdinal(Name, other.Name);
if (nameCompare != 0)
return nameCompare;
// Smaller number goes first
if (Number > other.Number)
return 1;
else if (Number < other.Number)
return -1;
else
return 0;
}
}
@@ -1,5 +1,7 @@
using Content.Shared._DV.CartridgeLoader.Cartridges;
using Content.Shared._Funkystation.NanoChat;
using Content.Shared.Examine;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
namespace Content.Shared._DV.NanoChat;
@@ -10,11 +12,13 @@ namespace Content.Shared._DV.NanoChat;
public abstract class SharedNanoChatSystem : EntitySystem
{
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<NanoChatCardComponent, ExaminedEvent>(OnExamined);
_prototypeManager.PrototypesReloaded += (_) => NanoChatEmojis.ClearCache();
}
private void OnExamined(Entity<NanoChatCardComponent> ent, ref ExaminedEvent args)
@@ -36,6 +40,9 @@ public abstract class SharedNanoChatSystem : EntitySystem
/// </summary>
public static string Truncate(string text, int maxLength, string overflowText = "...")
{
if (string.IsNullOrEmpty(text))
return string.Empty;
return text.Length > maxLength
? text[..(maxLength - overflowText.Length)] + overflowText
: text;
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Shared._Funkystation.NanoChat;
/// <summary>
/// Prototype for NanoChat emoji definitions.
/// </summary>
[Prototype]
public sealed partial class NanoChatEmojiPrototype : IPrototype
{
/// <summary>
/// The unique identifier for this emoji (e.g., "godo")
/// </summary>
[IdDataField]
public string ID { get; private set; } = string.Empty;
/// <summary>
/// Optional category for grouping emojis (e.g., "Godo", "Plushies")
/// </summary>
[DataField]
public string Category { get; private set; } = string.Empty;
/// <summary>
/// The sprite specifier for this emoji
/// </summary>
[DataField(required: true)]
public SpriteSpecifier Sprite = default!;
}
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
// SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
using System.Linq;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Shared._Funkystation.NanoChat;
/// <summary>
/// Shared emoji definitions for NanoChat.
/// This class provides access to emoji prototypes.
/// </summary>
public static class NanoChatEmojis
{
private static Dictionary<string, SpriteSpecifier>? _cachedEmojis;
/// <summary>
/// Dictionary mapping emoji names to their sprite specifiers.
/// </summary>
public static IReadOnlyDictionary<string, SpriteSpecifier> EmojiSpecifiers
{
get
{
if (_cachedEmojis == null)
{
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
_cachedEmojis = new Dictionary<string, SpriteSpecifier>();
var sortedEmojis = prototypeManager.EnumeratePrototypes<NanoChatEmojiPrototype>()
.OrderBy(proto => proto.Category)
.ThenBy(proto => proto.ID);
foreach (var proto in sortedEmojis)
{
_cachedEmojis[proto.ID] = proto.Sprite;
}
}
return _cachedEmojis;
}
}
/// <summary>
/// Clears the emoji cache. Call this if prototypes are reloaded.
/// </summary>
public static void ClearCache()
{
_cachedEmojis = null;
}
}
@@ -188,6 +188,7 @@ nano-chat-title = NanoChat
nano-chat-new-chat = New Chat
nano-chat-contacts = CONTACTS
nano-chat-no-chats = No active chats
nano-chat-no-id-card = No ID card inserted
nano-chat-select-chat = Select a chat to begin
nano-chat-message-placeholder = Type a message...
nano-chat-send = Send
@@ -0,0 +1,39 @@
# SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
#
# SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
# Group chat
nano-chat-create-group = Create Group Chat
nano-chat-invite-to-group = Invite to Group
nano-chat-view-members = View Members
nano-chat-invite = Invite
nano-chat-kick = Kick
nano-chat-leave = Leave
nano-chat-admin = Make Admin
nano-chat-deadmin = Remove Admin
nano-chat-no-results = No results found
nano-chat-no-members = No members
nano-chat-close = Close
# Create group chat popup
nano-chat-create-group-title = Create Group Chat
nano-chat-group-name-label = Group Name
nano-chat-group-name-placeholder = Enter a group name
# Invite to group popup
nano-chat-invite-to-group-title = Invite to Group
nano-chat-search-label = Search
nano-chat-search-placeholder = Search by name or number...
# Group members popup
nano-chat-group-members-title = Group Members
nano-chat-owner-suffix = (Owner)
nano-chat-admin-suffix = (Admin)
# Emoji picker
nano-chat-emoji-picker = Emoji Picker
nano-chat-emoji-search = Search emojis...
# Department sorting
nano-chat-other-department = Other
nano-chat-unknown-sender = Unknown
@@ -0,0 +1,313 @@
# SPDX-FileCopyrightText: 2025 Evaisa <evagiacosa1@gmail.com>
# SPDX-FileCopyrightText: 2025 EvaisaDev <mail@evaisa.dev>
#
# SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
# Godo emotes
- type: nanoChatEmoji
id: godo
category: Godo
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/godo.png
- type: nanoChatEmoji
id: do
category: Godo
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/do.png
- type: nanoChatEmoji
id: donow
category: Godo
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/donow.png
- type: nanoChatEmoji
id: gocrazy
category: Godo
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/donot.png
- type: nanoChatEmoji
id: gosad
category: Godo
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/dontdo.png
- type: nanoChatEmoji
id: godover
category: Godo
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/godover.png
- type: nanoChatEmoji
id: golove
category: Godo
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/golove.png
# Character emotes
- type: nanoChatEmoji
id: buzz
category: Character
sprite: /Textures/Interface/Emotes/buzztwo.png
- type: nanoChatEmoji
id: chime
category: Character
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/chime.png
- type: nanoChatEmoji
id: chirp
category: Character
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/chirp.png
- type: nanoChatEmoji
id: clap
category: Character
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/clap.png
- type: nanoChatEmoji
id: click
category: Character
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/click.png
- type: nanoChatEmoji
id: cough
category: Character
sprite: /Textures/Interface/Emotes/cough.png
- type: nanoChatEmoji
id: cry
category: Character
sprite: /Textures/Interface/Emotes/cry.png
- type: nanoChatEmoji
id: deathgasp
category: Character
sprite: /Textures/Interface/Emotes/deathgasp.png
- type: nanoChatEmoji
id: hew
category: Character
sprite: /Textures/Interface/Emotes/hew.png
- type: nanoChatEmoji
id: honk
category: Character
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/honk.png
- type: nanoChatEmoji
id: laugh
category: Character
sprite: /Textures/Interface/Emotes/laugh.png
- type: nanoChatEmoji
id: salute
category: Character
sprite: /Textures/Interface/Emotes/salute.png
- type: nanoChatEmoji
id: scream
category: Character
sprite: /Textures/Interface/Actions/scream.png
- type: nanoChatEmoji
id: sigh
category: Character
sprite: /Textures/Interface/Emotes/sigh.png
- type: nanoChatEmoji
id: snap
category: Character
sprite: /Textures/Interface/Emotes/snap.png
- type: nanoChatEmoji
id: squeak
category: Character
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/squeak.png
- type: nanoChatEmoji
id: tailslap
category: Character
sprite: /Textures/Interface/Emotes/tailslap.png
- type: nanoChatEmoji
id: vocal
category: Character
sprite: /Textures/Interface/Emotes/vocal.png
- type: nanoChatEmoji
id: weh
category: Character
sprite: /Textures/Interface/Emotes/weh.png
- type: nanoChatEmoji
id: whistle
category: Character
sprite: /Textures/Interface/Emotes/whistle.png
- type: nanoChatEmoji
id: yawn
category: Character
sprite: /Textures/Interface/Emotes/yawn.png
# Plushie emotes
- type: nanoChatEmoji
id: bee
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/bee.rsi
state: icon
- type: nanoChatEmoji
id: ratvar
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/ratvar.rsi
state: icon
- type: nanoChatEmoji
id: penguin
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/penguin.rsi
state: icon
- type: nanoChatEmoji
id: moth
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/moth.rsi
state: icon
- type: nanoChatEmoji
id: rouny
category: Plushies
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/rouny.png
- type: nanoChatEmoji
id: lizard
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/lizard.rsi
state: icon
- type: nanoChatEmoji
id: spacelizard
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/lizard.rsi
state: plushie_spacelizard
- type: nanoChatEmoji
id: hampter
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/hampter.rsi
state: icon
- type: nanoChatEmoji
id: arachnid
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/arachnid.rsi
state: icon
- type: nanoChatEmoji
id: lamp
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/lamp.rsi
state: icon
- type: nanoChatEmoji
id: nukie
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/nukie.rsi
state: icon
- type: nanoChatEmoji
id: atmosian
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/atmosian.rsi
state: icon
- type: nanoChatEmoji
id: slime
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/slime.rsi
state: icon
- type: nanoChatEmoji
id: snake
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/snake.rsi
state: icon
- type: nanoChatEmoji
id: vox
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/vox.rsi
state: icon
- type: nanoChatEmoji
id: diona
category: Plushies
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/plushie_diona.png
- type: nanoChatEmoji
id: human
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/human.rsi
state: icon
- type: nanoChatEmoji
id: xeno
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/xeno.rsi
state: icon
- type: nanoChatEmoji
id: carp
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/carp.rsi
state: carpplush
- type: nanoChatEmoji
id: rainbowcarp
category: Plushies
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/rainbowcarp.png
- type: nanoChatEmoji
id: rainbowlizard
category: Plushies
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/rainbowlizardplush.png
- type: nanoChatEmoji
id: nar
category: Plushies
sprite:
sprite: /Textures/Objects/Fun/Plushies/narsie.rsi
state: icon
- type: nanoChatEmoji
id: mothroach
category: Plushies
sprite:
sprite: /Textures/_Funkystation/Objects/Fun/toys.rsi
state: plushie_mothroach
- type: nanoChatEmoji
id: cosmic
category: Plushies
sprite:
sprite: /Textures/_DV/CosmicCult/Objects/unknownplushie.rsi
state: plushie_cosmic
# Other
- type: nanoChatEmoji
id: floorgoblin
category: Misc
sprite: /Textures/_Funkystation/Interface/NanoChat/Emotes/floorgoblin.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

@@ -0,0 +1,14 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "meijunkie on Discord - https://funkystation.org",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "plushie_mothroach"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B